From 1b59dc2ac8c4cc19a1cc66f2297b338ea4d00bc2 Mon Sep 17 00:00:00 2001 From: AEmotionStudio <163354043+AEmotionStudio@users.noreply.github.com> Date: Wed, 28 Jan 2026 01:00:10 -0800 Subject: [PATCH] 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) --- .gitignore | 12 + package.json | 25 + tsconfig.json | 39 + vitest.config.ts | 26 + web/advanced_comparer.js | 1913 +++++---------- web/gradient_title.js | 190 +- web/matrix_button.js | 240 +- web/noise_visualizer.js | 1173 +++------ web/shader_params_save_button.js | 535 ++-- web/shader_renderer.js | 2937 +++++----------------- web/src/advanced_comparer.ts | 904 +++++++ web/src/gradient_title.ts | 322 +++ web/src/matrix_button.ts | 3361 ++++++++++++++++++++++++++ web/src/noise_visualizer.ts | 1180 +++++++++ web/src/shader_params_save_button.ts | 640 +++++ web/src/shader_renderer.ts | 614 +++++ web/src/video_comparer.ts | 1178 +++++++++ web/tests/integration.test.ts | 208 ++ web/tests/matrix_button.test.ts | 284 +++ web/tests/mocks/comfyui.ts | 119 + web/tests/noise_visualizer.test.ts | 197 ++ web/tests/setup.ts | 136 ++ web/tests/shader_rendering.test.ts | 192 ++ web/types/comfyui.js | 6 + web/types/comfyui.js.map | 1 + web/types/comfyui.ts | 122 + web/types/litegraph.js | 6 + web/types/litegraph.js.map | 1 + web/types/litegraph.ts | 331 +++ web/video_comparer.js | 2214 ++++------------- 30 files changed, 12135 insertions(+), 6971 deletions(-) create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 web/src/advanced_comparer.ts create mode 100644 web/src/gradient_title.ts create mode 100644 web/src/matrix_button.ts create mode 100644 web/src/noise_visualizer.ts create mode 100644 web/src/shader_params_save_button.ts create mode 100644 web/src/shader_renderer.ts create mode 100644 web/src/video_comparer.ts create mode 100644 web/tests/integration.test.ts create mode 100644 web/tests/matrix_button.test.ts create mode 100644 web/tests/mocks/comfyui.ts create mode 100644 web/tests/noise_visualizer.test.ts create mode 100644 web/tests/setup.ts create mode 100644 web/tests/shader_rendering.test.ts create mode 100644 web/types/comfyui.js create mode 100644 web/types/comfyui.js.map create mode 100644 web/types/comfyui.ts create mode 100644 web/types/litegraph.js create mode 100644 web/types/litegraph.js.map create mode 100644 web/types/litegraph.ts diff --git a/.gitignore b/.gitignore index a322452..a3f485d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..8abc407 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2d0e96a --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..c5fea6b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['web/tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['web/src/**/*.ts'], + exclude: ['web/types/**', 'web/tests/**'], + }, + setupFiles: ['web/tests/setup.ts'], + }, + resolve: { + alias: { + // Mock ComfyUI imports during testing - use absolute paths + '../../scripts/app.js': path.resolve(__dirname, 'web/tests/mocks/comfyui.ts'), + '../../../scripts/app.js': path.resolve(__dirname, 'web/tests/mocks/comfyui.ts'), + '../../scripts/api.js': path.resolve(__dirname, 'web/tests/mocks/comfyui.ts'), + '../../../scripts/api.js': path.resolve(__dirname, 'web/tests/mocks/comfyui.ts'), + }, + }, +}); diff --git a/web/advanced_comparer.js b/web/advanced_comparer.js index 91e7997..5117834 100644 --- a/web/advanced_comparer.js +++ b/web/advanced_comparer.js @@ -1,13 +1,15 @@ -// AdvancedImageComparer.js -// Implementation based on rgthree's image_comparer.js but simplified and enhanced for batch comparison -// Now includes auto-fill slot functionality - +/** + * 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"; - console.log("AdvancedImageComparer module loaded"); - -// Cache for rendering optimization +// ============================ +// Cache and Helpers +// ============================ const CACHE = { titleCanvas: null, titleCtx: null, @@ -15,131 +17,91 @@ const CACHE = { lastHeight: 0, lastTime: 0, frameCount: 0, - frameSkip: 2, // Only update animation every X frames + frameSkip: 2, collapsed: { canvas: null, ctx: null, lastWidth: 0 } }; - function imageDataToUrl(data) { - return api.apiURL(`/view?filename=${encodeURIComponent(data.filename)}&type=${encodeURIComponent(data.type || "")}&subfolder=${encodeURIComponent(data.subfolder || "")}${app.getPreviewFormatParam()}${app.getRandParam()}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apiObj = api; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const appObj = app; + return apiObj.apiURL(`/view?filename=${encodeURIComponent(data.filename)}&type=${encodeURIComponent(data.type || "")}&subfolder=${encodeURIComponent(data.subfolder || "")}${appObj.getPreviewFormatParam()}${appObj.getRandParam()}`); } - -/** - * Draws a custom golden eyeball using canvas drawing commands - * @param {CanvasRenderingContext2D} ctx - The canvas context - * @param {number} centerX - X center position - * @param {number} centerY - Y center position - * @param {number} size - Size of the eyeball - * @param {number} shimmerPosition - Position of the shimmer effect (0-1) - */ function drawGoldenEyeball(ctx, centerX, centerY, size, shimmerPosition) { - // Refined proportions for smaller, cleaner look const eyeWidth = size * 1.6; const eyeHeight = size * 1.0; const irisRadius = size * 0.35; const pupilRadius = size * 0.15; - ctx.save(); - - // Create base golden gradient exactly like the mathematical formula - const baseGradient = ctx.createLinearGradient(0, centerY - size*0.7, 0, centerY + size*0.7); - baseGradient.addColorStop(0, "#B8860B"); // Darker gold - baseGradient.addColorStop(0.5, "#FFD700"); // Bright gold - baseGradient.addColorStop(1, "#B8860B"); // Darker gold - - // Create moving highlight effect (matching gradient_title.js exactly) - const highlightWidth = eyeWidth * 0.4; // Width of the highlight (same as formula) - const highlightX = -highlightWidth + (eyeWidth + highlightWidth) * shimmerPosition; // Adjusted range - - const shimmerGradient = ctx.createLinearGradient( - centerX + highlightX - highlightWidth/2, 0, - centerX + highlightX + highlightWidth/2, 0 - ); - - // Create smooth highlight transition (exact same as formula) + 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 etched shadow for all outlines (matching formula style) + // Draw shadows ctx.strokeStyle = "rgba(0,0,0,0.3)"; ctx.lineWidth = 1.5; ctx.lineCap = "round"; - - // Shadow for main eye outline ctx.beginPath(); - ctx.ellipse(centerX + 2, centerY + 2, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2); + ctx.ellipse(centerX + 2, centerY + 2, eyeWidth / 2, eyeHeight / 2, 0, 0, Math.PI * 2); ctx.stroke(); - - // Shadow for iris ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(centerX + 2, centerY + 2, irisRadius, 0, Math.PI * 2); ctx.stroke(); - - // Shadow for pupil ctx.beginPath(); ctx.arc(centerX + 2, centerY + 2, pupilRadius, 0, Math.PI * 2); ctx.stroke(); - - // Draw 8 eyelashes/rays around the eye - shadows first + // Draw rays const rayCount = 8; const rayLength = size * 0.7; - ctx.lineWidth = 1; - for (let i = 0; i < rayCount; i++) { const angle = (i / rayCount) * Math.PI * 2; - const startX = centerX + Math.cos(angle) * (eyeWidth/2 + 1); - const startY = centerY + Math.sin(angle) * (eyeHeight/2 + 1); - const endX = centerX + Math.cos(angle) * (eyeWidth/2 + rayLength); - const endY = centerY + Math.sin(angle) * (eyeHeight/2 + rayLength); - + const startX = centerX + Math.cos(angle) * (eyeWidth / 2 + 1); + const startY = centerY + Math.sin(angle) * (eyeHeight / 2 + 1); + const endX = centerX + Math.cos(angle) * (eyeWidth / 2 + rayLength); + const endY = centerY + Math.sin(angle) * (eyeHeight / 2 + rayLength); ctx.beginPath(); ctx.moveTo(startX + 2, startY + 2); ctx.lineTo(endX + 2, endY + 2); ctx.stroke(); } - - // Now draw the golden base outlines + // Draw golden outlines ctx.strokeStyle = baseGradient; ctx.lineWidth = 1.5; - - // Main eye outline ctx.beginPath(); - ctx.ellipse(centerX, centerY, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2); + ctx.ellipse(centerX, centerY, eyeWidth / 2, eyeHeight / 2, 0, 0, Math.PI * 2); ctx.stroke(); - - // Iris outline ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(centerX, centerY, irisRadius, 0, Math.PI * 2); ctx.stroke(); - - // Pupil outline ctx.beginPath(); ctx.arc(centerX, centerY, pupilRadius, 0, Math.PI * 2); ctx.stroke(); - - // Draw 8 eyelashes/rays with base golden color for (let i = 0; i < rayCount; i++) { const angle = (i / rayCount) * Math.PI * 2; - const startX = centerX + Math.cos(angle) * (eyeWidth/2 + 1); - const startY = centerY + Math.sin(angle) * (eyeHeight/2 + 1); - const endX = centerX + Math.cos(angle) * (eyeWidth/2 + rayLength); - const endY = centerY + Math.sin(angle) * (eyeHeight/2 + rayLength); - + const startX = centerX + Math.cos(angle) * (eyeWidth / 2 + 1); + const startY = centerY + Math.sin(angle) * (eyeHeight / 2 + 1); + const endX = centerX + Math.cos(angle) * (eyeWidth / 2 + rayLength); + const endY = centerY + Math.sin(angle) * (eyeHeight / 2 + rayLength); ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(endX, endY); ctx.stroke(); } - - // Add subtle iris texture lines (fewer for cleaner look) + // Iris texture ctx.lineWidth = 0.5; for (let i = 0; i < 8; i++) { const angle = (i / 8) * Math.PI * 2; @@ -148,1625 +110,832 @@ function drawGoldenEyeball(ctx, centerX, centerY, size, shimmerPosition) { ctx.lineTo(centerX + Math.cos(angle) * irisRadius * 0.9, centerY + Math.sin(angle) * irisRadius * 0.9); ctx.stroke(); } - - // Apply shimmer highlight effect to all outlines + // Shimmer effect ctx.strokeStyle = shimmerGradient; ctx.lineWidth = 1.5; - - // Shimmer on main eye outline ctx.beginPath(); - ctx.ellipse(centerX, centerY, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2); + ctx.ellipse(centerX, centerY, eyeWidth / 2, eyeHeight / 2, 0, 0, Math.PI * 2); ctx.stroke(); - - // Shimmer on iris ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(centerX, centerY, irisRadius, 0, Math.PI * 2); ctx.stroke(); - - // Shimmer on pupil ctx.beginPath(); ctx.arc(centerX, centerY, pupilRadius, 0, Math.PI * 2); ctx.stroke(); - - // Shimmer on 8 eyelashes/rays for (let i = 0; i < rayCount; i++) { const angle = (i / rayCount) * Math.PI * 2; - const startX = centerX + Math.cos(angle) * (eyeWidth/2 + 1); - const startY = centerY + Math.sin(angle) * (eyeHeight/2 + 1); - const endX = centerX + Math.cos(angle) * (eyeWidth/2 + rayLength); - const endY = centerY + Math.sin(angle) * (eyeHeight/2 + rayLength); - + const startX = centerX + Math.cos(angle) * (eyeWidth / 2 + 1); + const startY = centerY + Math.sin(angle) * (eyeHeight / 2 + 1); + const endX = centerX + Math.cos(angle) * (eyeWidth / 2 + rayLength); + const endY = centerY + Math.sin(angle) * (eyeHeight / 2 + rayLength); ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(endX, endY); ctx.stroke(); } - - // Add outline glow that follows the highlight (matching formula exactly) - const glowIntensity = Math.max(0, 1 - Math.abs(centerX - (centerX + highlightX))/(eyeWidth/4)); + // 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; - - // Final glow pass on main outline ctx.strokeStyle = baseGradient; ctx.lineWidth = 1.5; ctx.beginPath(); - ctx.ellipse(centerX, centerY, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2); + ctx.ellipse(centerX, centerY, eyeWidth / 2, eyeHeight / 2, 0, 0, Math.PI * 2); ctx.stroke(); - ctx.restore(); } - -/** - * Draws a gradient background with golden eyeball title - * @param {LGraphNode} node - The node to apply the gradient to - * @param {CanvasRenderingContext2D} ctx - The canvas context - */ function drawGradientTitle(node, ctx) { - // Get title area dimensions - const titleHeight = node.flags.collapsed ? 20 : 30; // Smaller height when collapsed - const width = node.flags.collapsed ? 190 : node.size[0]; // Smaller width when collapsed - const fullHeight = node.size[1]; // Get actual node height - const eyeballY = node.flags.collapsed ? titleHeight / 2 : 25; // Much closer to top - - // Eyeball size based on collapsed state (made smaller) + const 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; - - // Update animation frame counter (performance optimization) CACHE.frameCount = (CACHE.frameCount + 1) % (CACHE.frameSkip + 1); const shouldUpdateAnimation = CACHE.frameCount === 0; - - // Save current state ctx.save(); - - // Reset shadow properties for gradient drawing ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; - - // Create vertical black gradient for entire background that fills node height const gradient = ctx.createLinearGradient(0, 0, 0, fullHeight); - gradient.addColorStop(0, "#000000"); // Pure black at top - gradient.addColorStop(0.2, "#101010"); // Transition to very dark gray - gradient.addColorStop(1, "#101010"); // Very dark gray at bottom - - // Create smooth shimmer effect for eyeball - only calculate if animation should update - let shimmerPosition = 0.5; // Default middle position + gradient.addColorStop(0, "#000000"); + gradient.addColorStop(0.2, "#101010"); + gradient.addColorStop(1, "#101010"); + let shimmerPosition = 0.5; 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 + const time = Date.now() / 3000; + shimmerPosition = (Math.sin(time) + 1) / 2; CACHE.lastTime = time; - } else { - // Reuse last calculation for animation frames we're skipping + } + else { const time = CACHE.lastTime || Date.now() / 3000; shimmerPosition = (Math.sin(time) + 1) / 2; } - - // Add collapse button handler if (node.flags.collapsed) { - // If node is collapsed, adjust the title rendering ctx.fillStyle = gradient; ctx.fillRect(0, 0, width, titleHeight); - - // Draw custom golden eyeball for collapsed version drawGoldenEyeball(ctx, width / 2, titleHeight / 2, eyeballSize, shimmerPosition); - - // Skip the rest of the rendering when collapsed ctx.restore(); return; } - - // Draw background that fills the entire node ctx.fillStyle = gradient; - - // Use rounded rectangle for the background with rounded corners at the bottom if (!node.flags.collapsed) { - const cornerRadius = 8; // Adjust radius as needed - - // Create path for rounded rectangle + const cornerRadius = 8; 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.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 { - // Keep regular rectangle for collapsed state - ctx.fillRect(0, 0, width, fullHeight); // No extra padding } - - // Draw custom golden eyeball for expanded version + else { + ctx.fillRect(0, 0, width, fullHeight); + } drawGoldenEyeball(ctx, width / 2, eyeballY, eyeballSize, shimmerPosition); - - // Restore context state ctx.restore(); } - +// ============================ +// Widget Class +// ============================ class AdvancedImageComparerWidget { constructor(name, node) { - this.name = name; this.type = "custom"; - this.node = node; - this._value = { images: [] }; - this.selected = []; - this.imgs = []; this.options = { serialize: false }; this.y = 0; this.last_y = 0; - - // Enhanced batch handling properties this.imagesA = []; this.imagesB = []; this.currentPairIndex = 0; this.maxPairs = 0; this.animationFrame = null; this.autoPlayEnabled = false; - this.autoPlaySpeed = 2000; // 2 seconds per pair - - // Batch pagination properties + this.autoPlaySpeed = 2000; this.currentBatchPage = 0; - this.pairsPerPage = 3; // Show 3 pairs per page in batch mode + this.pairsPerPage = 3; this.maxBatchPages = 0; + this.name = name; + this.node = node; + this._value = { images: [] }; + this.selected = []; + this.imgs = []; } - set value(v) { - // Process the images from the execution result const images = v.images || []; - - const imagesA = images.filter(img => img.is_image_a); - const imagesB = images.filter(img => img.is_image_b); - - // Store all images for batch processing + const imagesA = images.filter((img) => 'is_image_a' in img && img.is_image_a === true); + const imagesB = images.filter((img) => 'is_image_a' in img && img.is_image_a === false); this.imagesA = imagesA.map((img, index) => ({ name: `A${index + 1}`, - selected: true, + selected: true, url: imageDataToUrl(img), img: null, - index: index + index })); - this.imagesB = imagesB.map((img, index) => ({ name: `B${index + 1}`, - selected: true, + selected: true, url: imageDataToUrl(img), img: null, - index: index + index })); - - // Calculate max pairs for comparison this.maxPairs = Math.max(this.imagesA.length, this.imagesB.length); this.currentPairIndex = 0; - - // Calculate batch pagination this.maxBatchPages = Math.ceil(this.maxPairs / this.pairsPerPage); this.currentBatchPage = 0; - - // Set the value and update selected pair this._value = { images: [...this.imagesA, ...this.imagesB] }; this.updateSelectedPair(); - - // Load all images for batch modes this.loadAllImages(); - - // Update controls when images change - if (this.node && this.node.updateControlsVisibility) { + if (this.node?.updateControlsVisibility) { this.node.updateControlsVisibility(); } - - // Force a size recalculation to ensure proper initial display with larger minimums if (this.node) { - setTimeout(() => { - const computedSize = this.node.computeSize(); - const currentSize = this.node.size; - const minWidth = 700; // Ensure minimum size for prominent display - const minHeight = 600; // Ensure minimum size for prominent display - - // Use the larger of computed size or our minimums - const targetSize = [ - Math.max(computedSize[0], minWidth), - Math.max(computedSize[1], minHeight) - ]; - - // Resize if target is larger than current - if (targetSize[0] > currentSize[0] || targetSize[1] > currentSize[1]) { - console.log(`[Widget] Forcing size update: [${currentSize[0]}, ${currentSize[1]}] → [${targetSize[0]}, ${targetSize[1]}]`); - this.node.setSize(targetSize); - } - }, 50); - } - - // Immediately ensure node is large enough when images are set - be very aggressive - if (this.node) { - const currentSize = this.node.size; - const minWidth = 700; // Even larger minimum width for prominent image display - const minHeight = 600; // Even larger minimum height for prominent image display - - // Always force resize to ensure prominent display - don't just check if too small - const newSize = [ - Math.max(currentSize[0], minWidth), - Math.max(currentSize[1], minHeight) - ]; - - // Force the resize immediately and synchronously - console.log(`[Widget] Ensuring prominent sizing from [${currentSize[0]}, ${currentSize[1]}] to [${newSize[0]}, ${newSize[1]}]`); - this.node.setSize(newSize); - - // Force immediate canvas update with the new size + 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); - - // Also force a delayed update to ensure everything is properly refreshed - setTimeout(() => { - this.node.setDirtyCanvas(true, true); - }, 100); } } - get value() { return this._value || { images: [] }; } - loadAllImages() { - const allImages = [...this.imagesA, ...this.imagesB]; - - allImages.forEach(imageData => { + [...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 = (error) => { - console.error("[AdvancedImageComparer] Image failed to load:", imageData.name, "URL:", imageData.url, "Error:", error); - }; + 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) { this.selected = selected; this.imgs = []; - for (const sel of selected) { if (!sel.img && sel.url) { sel.img = new Image(); - sel.img.onload = () => { - this.node.setDirtyCanvas(true, false); - }; - sel.img.onerror = (error) => { - console.error("[AdvancedImageComparer] Image failed to load:", sel.name, "URL:", sel.url, "Error:", error); - }; + sel.img.onload = () => this.node.setDirtyCanvas(true, false); sel.img.src = sel.url; } - if (sel.img) { + if (sel.img) this.imgs.push(sel.img); - } } } - draw(ctx, node, width, y, height) { this.y = y; this.last_y = y; - - // Calculate the actual available height for images - maximize space for image display - const [nodeWidth, nodeHeight] = node.size; - const availableHeight = Math.max(200, nodeHeight - y - 10); // Increased minimum, minimal padding for maximum image space - - // Ensure we have adequate space - if not, this might be a timing issue with resize - if (availableHeight < 250 && this.value.images && this.value.images.length > 0) { - console.log(`[Widget] Draw called with small availableHeight: ${availableHeight}, node size: [${nodeWidth}, ${nodeHeight}]`); - } - + 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: // "Slider" + default: this.drawSliderMode(ctx, y, width, availableHeight); break; } - - // Draw controls for batch modes (excluding Carousel which uses widgets) if (["Grid", "Batch"].includes(mode) && this.maxPairs > 1) { this.drawBatchControls(ctx, y, width, availableHeight); } - - // Draw pair indicator for Carousel mode if (mode === "Carousel" && this.maxPairs > 1) { this.drawPairIndicator(ctx, y + availableHeight - 25, width); } } - - drawClickMode(ctx, y, width, availableHeight) { - const imageIndex = this.node.isPointerDown ? 1 : 0; - this.drawImage(ctx, this.selected[imageIndex], y, width, availableHeight); + drawClickMode(ctx, y, width, h) { + const idx = this.node.isPointerDown ? 1 : 0; + this.drawImage(ctx, this.selected[idx], y, width, h); } - - drawSideBySideMode(ctx, y, width, availableHeight) { - if (this.selected[0]) { - this.drawImageSideBySide(ctx, this.selected[0], y, width, availableHeight, 0); - } - if (this.selected[1]) { - this.drawImageSideBySide(ctx, this.selected[1], y, width, availableHeight, 1); - } + drawSideBySideMode(ctx, y, width, h) { + 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, y, width, availableHeight) { - if (this.selected[0]) { - this.drawImageStacked(ctx, this.selected[0], y, width, availableHeight, 0); - } - if (this.selected[1]) { - this.drawImageStacked(ctx, this.selected[1], y, width, availableHeight, 1); - } + drawStackedMode(ctx, y, width, h) { + 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, y, width, availableHeight) { - if (this.selected[0]) { - this.drawImage(ctx, this.selected[0], y, width, availableHeight); - } - + drawSliderMode(ctx, y, width, h) { + if (this.selected[0]) + this.drawImage(ctx, this.selected[0], y, width, h); if (this.selected[1] && this.node.isPointerOver) { - const cropX = this.node.pointerOverPos[0]; - this.drawImage(ctx, this.selected[1], y, width, availableHeight, cropX); + this.drawImage(ctx, this.selected[1], y, width, h, this.node.pointerOverPos[0]); } } - - drawGridMode(ctx, y, width, availableHeight) { - // Calculate grid layout - const pairs = Math.min(this.maxPairs, 64); // Show max 64 pairs in grid - const cols = Math.ceil(Math.sqrt(pairs * 2)); // 2 images per pair + drawGridMode(ctx, y, width, h) { + const pairs = Math.min(this.maxPairs, 64); + const cols = Math.ceil(Math.sqrt(pairs * 2)); const rows = Math.ceil((pairs * 2) / cols); - - const cellWidth = width / cols; - const cellHeight = (availableHeight - 40) / rows; // Reserve space for controls - - let cellIndex = 0; + const cw = width / cols, ch = (h - 40) / rows; + let ci = 0; for (let i = 0; i < pairs; i++) { - const imageA = this.imagesA[i]; - const imageB = this.imagesB[i]; - - if (imageA && imageA.img) { - const col = cellIndex % cols; - const row = Math.floor(cellIndex / cols); - this.drawImageInCell(ctx, imageA, y + row * cellHeight, col * cellWidth, cellWidth, cellHeight, `A${i + 1}`); - cellIndex++; + if (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 (imageB && imageB.img) { - const col = cellIndex % cols; - const row = Math.floor(cellIndex / cols); - this.drawImageInCell(ctx, imageB, y + row * cellHeight, col * cellWidth, cellWidth, cellHeight, `B${i + 1}`); - cellIndex++; + 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, y, width, availableHeight) { - const imageA = this.imagesA[this.currentPairIndex]; - const imageB = this.imagesB[this.currentPairIndex]; - - // Draw current pair side by side (use full available height since controls are now widgets) - if (imageA && imageA.img) { - this.drawImageSideBySide(ctx, imageA, y, width, availableHeight, 0); - } - if (imageB && imageB.img) { - this.drawImageSideBySide(ctx, imageB, y, width, availableHeight, 1); - } + drawCarouselMode(ctx, y, width, h) { + 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, y, width, availableHeight) { - const pairHeight = (availableHeight - 40) / this.pairsPerPage; // Reserve space for controls - const startPairIndex = this.currentBatchPage * this.pairsPerPage; - const endPairIndex = Math.min(startPairIndex + this.pairsPerPage, this.maxPairs); - - for (let i = 0; i < this.pairsPerPage; i++) { - const pairIndex = startPairIndex + i; - if (pairIndex >= this.maxPairs) break; - - const imageA = this.imagesA[pairIndex]; - const imageB = this.imagesB[pairIndex]; - const pairY = y + i * pairHeight; - - if (imageA && imageA.img) { - this.drawImageInPair(ctx, imageA, pairY, 0, width / 2, pairHeight, 0); - } - if (imageB && imageB.img) { - this.drawImageInPair(ctx, imageB, pairY, width / 2, width / 2, pairHeight, 1); - } - - // Draw separator between pairs - if (i < this.pairsPerPage - 1 && pairIndex < this.maxPairs - 1) { + drawBatchMode(ctx, y, width, h) { + 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, pairY + pairHeight); - ctx.lineTo(width, pairY + pairHeight); + ctx.moveTo(0, py + ph); + ctx.lineTo(width, py + ph); ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.lineWidth = 1; ctx.stroke(); } } } - - drawImageInCell(ctx, imageData, y, x, cellWidth, cellHeight, label) { - if (!imageData || !imageData.img || !imageData.img.naturalWidth || !imageData.img.naturalHeight) { - return; - } - - const image = imageData.img; - const padding = 2; // Minimal padding for grid cells to maximize image space - const usableWidth = cellWidth - padding * 2; - const usableHeight = cellHeight - padding * 2; - - const imageAspect = image.naturalWidth / image.naturalHeight; - const cellAspect = usableWidth / usableHeight; - - let targetWidth, targetHeight; - - // Scale to fill most of the cell space - if (imageAspect > cellAspect) { - targetWidth = usableWidth; - targetHeight = usableWidth / imageAspect; - } else { - targetHeight = usableHeight; - targetWidth = usableHeight * imageAspect; - } - - const destX = x + padding + (usableWidth - targetWidth) / 2; - const destY = y + padding + (usableHeight - targetHeight) / 2; - - ctx.save(); - - // Clip to cell bounds - ctx.beginPath(); - ctx.rect(x + padding, y + padding, usableWidth, usableHeight); - ctx.clip(); - - // Draw border - ctx.strokeStyle = "rgba(255,255,255,0.3)"; - ctx.lineWidth = 1; - ctx.strokeRect(x + padding, y + padding, usableWidth, usableHeight); - - // Draw image - ctx.drawImage( - image, - 0, 0, image.naturalWidth, image.naturalHeight, - destX, destY, targetWidth, targetHeight - ); - - // Draw label - ctx.fillStyle = "rgba(0,0,0,0.8)"; - ctx.fillRect(destX, destY, 28, 16); - ctx.fillStyle = "white"; - ctx.font = "10px Arial"; - ctx.textAlign = "center"; - ctx.fillText(label, destX + 14, destY + 11); - - ctx.restore(); - } - - drawImageInPair(ctx, imageData, y, x, pairWidth, pairHeight, imageIndex) { - if (!imageData || !imageData.img || !imageData.img.naturalWidth || !imageData.img.naturalHeight) { - return; - } - - const image = imageData.img; - const padding = 3; // Minimal padding for batch pairs to maximize image space - const usableWidth = pairWidth - padding * 2; - const usableHeight = pairHeight - padding * 2; - - const imageAspect = image.naturalWidth / image.naturalHeight; - const pairAspect = usableWidth / usableHeight; - - let targetWidth, targetHeight; - - // Scale to fill most of the pair space - if (imageAspect > pairAspect) { - targetWidth = usableWidth; - targetHeight = usableWidth / imageAspect; - } else { - targetHeight = usableHeight; - targetWidth = usableHeight * imageAspect; - } - - const destX = x + padding + (usableWidth - targetWidth) / 2; - const destY = y + padding + (usableHeight - targetHeight) / 2; - - ctx.save(); - - // Clip to pair bounds - ctx.beginPath(); - ctx.rect(x + padding, y + padding, usableWidth, usableHeight); - ctx.clip(); - - // Draw image - ctx.drawImage( - image, - 0, 0, image.naturalWidth, image.naturalHeight, - destX, destY, targetWidth, targetHeight - ); - - // Draw label - ctx.fillStyle = "rgba(0,0,0,0.8)"; - ctx.fillRect(destX, destY, 30, 16); - ctx.fillStyle = "white"; - ctx.font = "11px Arial"; - ctx.textAlign = "center"; - ctx.fillText(imageData.name, destX + 15, destY + 11); - - ctx.restore(); - - // Draw separator line for side-by-side in batch mode (outside of clipping) - if (imageIndex === 0 && pairWidth < this.node.size[0]) { + drawOnionSkinMode(ctx, y, width, h) { + 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.beginPath(); - ctx.moveTo(x + pairWidth, y + padding); - ctx.lineTo(x + pairWidth, y + pairHeight - padding); - ctx.strokeStyle = "rgba(255,255,255,0.5)"; - ctx.lineWidth = 2; - ctx.stroke(); + ctx.globalAlpha = opacity; + this.drawImage(ctx, this.selected[1], y, width, h); ctx.restore(); } } - - drawBatchControls(ctx, y, width, availableHeight) { - const controlY = y + availableHeight - 30; - const mode = this.node.properties?.comparer_mode || "Slider"; - - ctx.save(); - - // Draw control background - ctx.fillStyle = "rgba(0,0,0,0.7)"; - ctx.fillRect(0, controlY, width, 30); - - ctx.fillStyle = "white"; - ctx.font = "12px Arial"; - ctx.textAlign = "left"; - - if (mode === "Carousel") { - // Carousel controls - const buttonWidth = 60; - const buttonHeight = 20; - const buttonY = controlY + 5; - - // Previous button - ctx.fillStyle = "rgba(100,100,100,0.8)"; - ctx.fillRect(10, buttonY, buttonWidth, buttonHeight); - ctx.fillStyle = "white"; - ctx.textAlign = "center"; - ctx.fillText("◀ Prev", 10 + buttonWidth/2, buttonY + 14); - - // Next button - ctx.fillStyle = "rgba(100,100,100,0.8)"; - ctx.fillRect(80, buttonY, buttonWidth, buttonHeight); - ctx.fillStyle = "white"; - ctx.fillText("Next ▶", 80 + buttonWidth/2, buttonY + 14); - - // Auto-play button - ctx.fillStyle = this.autoPlayEnabled ? "rgba(0,150,0,0.8)" : "rgba(100,100,100,0.8)"; - ctx.fillRect(150, buttonY, buttonWidth, buttonHeight); - ctx.fillStyle = "white"; - ctx.fillText(this.autoPlayEnabled ? "⏸ Pause" : "▶ Play", 150 + buttonWidth/2, buttonY + 14); - - // Pair indicator - ctx.textAlign = "right"; - ctx.fillStyle = "white"; - ctx.fillText(`${this.currentPairIndex + 1} / ${this.maxPairs}`, width - 10, buttonY + 14); - } else { - // General batch info - ctx.fillText(`Images: A(${this.imagesA.length}) B(${this.imagesB.length})`, 10, controlY + 18); - - if (mode === "Grid") { - ctx.textAlign = "right"; - ctx.fillText(`Showing ${Math.min(this.maxPairs, 64)} pairs`, width - 10, controlY + 18); - } else if (mode === "Batch") { - const startPair = this.currentBatchPage * this.pairsPerPage + 1; - const endPair = Math.min((this.currentBatchPage + 1) * this.pairsPerPage, this.maxPairs); - ctx.textAlign = "right"; - ctx.fillText(`Showing pairs ${startPair}-${endPair} of ${this.maxPairs}`, width - 10, controlY + 18); - } - } - - ctx.restore(); - } - - drawPairIndicator(ctx, y, width) { - ctx.save(); - - const dotSize = 8; - const dotSpacing = 12; - const totalWidth = this.maxPairs * dotSpacing - (dotSpacing - dotSize); - const startX = (width - totalWidth) / 2; - - for (let i = 0; i < this.maxPairs; i++) { - const x = startX + i * dotSpacing; - - ctx.beginPath(); - ctx.arc(x + dotSize/2, y + dotSize/2, dotSize/2, 0, Math.PI * 2); - - if (i === this.currentPairIndex) { - ctx.fillStyle = "rgba(255,255,255,1)"; - } else { - ctx.fillStyle = "rgba(255,255,255,0.4)"; - } - ctx.fill(); - } - - ctx.restore(); - } - drawImage(ctx, imageData, y, nodeWidth, availableHeight, cropX) { - if (!imageData) { + 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, th; + if (ia > ua) { + tw = uw; + th = uw / ia; } - - if (!imageData.img) { - return; + else { + th = uh; + tw = uh * ia; } - - if (!imageData.img.naturalWidth || !imageData.img.naturalHeight) { - return; - } - - const image = imageData.img; - - // Use nearly all available space for maximum image prominence - const padding = 3; // Minimal padding from node edges - const usableWidth = nodeWidth - (padding * 2); - const usableHeight = availableHeight - (padding * 2); - - const imageAspect = image.naturalWidth / image.naturalHeight; - const usableAspect = usableWidth / usableHeight; - - let targetWidth, targetHeight; - - // Scale image to fill most of the available space while maintaining aspect ratio - if (imageAspect > usableAspect) { - // Image is wider than available space - fit to width - targetWidth = usableWidth; - targetHeight = usableWidth / imageAspect; - } else { - // Image is taller than available space - fit to height - targetHeight = usableHeight; - targetWidth = usableHeight * imageAspect; - } - - // Center the image within the available space - const destX = padding + (usableWidth - targetWidth) / 2; - const destY = y + padding + (usableHeight - targetHeight) / 2; - - // Calculate crop parameters for slider mode - const widthMultiplier = image.naturalWidth / targetWidth; - const sourceX = 0; - const sourceY = 0; - const sourceWidth = cropX != null ? Math.max(0, (cropX - destX) * widthMultiplier) : image.naturalWidth; - const sourceHeight = image.naturalHeight; - const destWidth = cropX != null ? Math.max(0, cropX - destX) : targetWidth; - const destHeight = targetHeight; - + const dx = pad + (uw - tw) / 2, dy = y + pad + (uh - th) / 2; ctx.save(); - - // Clip to ensure image stays within bounds ctx.beginPath(); - ctx.rect(padding, y + padding, usableWidth, usableHeight); + ctx.rect(pad, y + pad, uw, uh); ctx.clip(); - - if (cropX && cropX > destX) { - // Draw cropped portion for slider mode - ctx.drawImage( - image, - sourceX, sourceY, sourceWidth, sourceHeight, - destX, destY, destWidth, destHeight - ); - } else { - // Draw full image - ctx.drawImage( - image, - 0, 0, image.naturalWidth, image.naturalHeight, - destX, destY, targetWidth, targetHeight - ); + 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); } - - // Draw slider line - if (cropX != null && cropX > destX && cropX < destX + targetWidth) { + 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, destY); - ctx.lineTo(cropX, destY + targetHeight); + 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(); } - - // Navigation methods for carousel mode - nextPair() { - if (this.currentPairIndex < this.maxPairs - 1) { - this.currentPairIndex++; - this.updateSelectedPair(); - this.node.setDirtyCanvas(true, false); + drawImageSideBySide(ctx, imageData, y, nodeWidth, availableHeight, imageIndex) { + 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, th; + 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(); } } - - previousPair() { - if (this.currentPairIndex > 0) { - this.currentPairIndex--; - this.updateSelectedPair(); - this.node.setDirtyCanvas(true, false); + drawImageStacked(ctx, imageData, y, nodeWidth, availableHeight, imageIndex) { + 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, th; + 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(); } } - - toggleAutoPlay() { - this.autoPlayEnabled = !this.autoPlayEnabled; - - if (this.autoPlayEnabled) { - this.startAutoPlay(); - } else { - this.stopAutoPlay(); + drawImageInCell(ctx, imageData, y, x, cw, ch, label) { + 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, th; + 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, imageData, y, x, pw, ph, imageIndex) { + 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, th; + 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, y, width, h) { + 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, y, width) { + 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() { if (this.currentPairIndex < this.maxPairs - 1) { + this.currentPairIndex++; + this.updateSelectedPair(); + this.node.setDirtyCanvas(true, false); + } } + previousPair() { if (this.currentPairIndex > 0) { + this.currentPairIndex--; + this.updateSelectedPair(); + this.node.setDirtyCanvas(true, false); + } } + toggleAutoPlay() { this.autoPlayEnabled = !this.autoPlayEnabled; this.autoPlayEnabled ? this.startAutoPlay() : this.stopAutoPlay(); this.updateNodeControls(); this.node.setDirtyCanvas(true, false); } + nextBatchPage() { if (this.currentBatchPage < this.maxBatchPages - 1) { + this.currentBatchPage++; this.updateNodeControls(); this.node.setDirtyCanvas(true, false); - } - - // Update the node's control widgets + } } + previousBatchPage() { if (this.currentBatchPage > 0) { + this.currentBatchPage--; + this.updateNodeControls(); + this.node.setDirtyCanvas(true, false); + } } updateNodeControls() { - if (this.node.pairInfoWidget) { + if (this.node.pairInfoWidget) this.node.pairInfoWidget.value = `${this.currentPairIndex + 1} / ${this.maxPairs}`; - } - if (this.node.autoPlayButton) { + if (this.node.autoPlayButton) this.node.autoPlayButton.name = this.autoPlayEnabled ? "⏸ Pause" : "▶ Play"; - } - if (this.node.batchSelectorWidget) { + if (this.node.batchSelectorWidget) this.node.batchSelectorWidget.value = (this.currentPairIndex + 1).toString(); - } - if (this.node.batchPageInfoWidget) { + if (this.node.batchPageInfoWidget) this.node.batchPageInfoWidget.value = `Page ${this.currentBatchPage + 1} / ${this.maxBatchPages}`; - } } - - // Update the selected pair for modes that show individual pairs updateSelectedPair() { const mode = this.node.properties?.comparer_mode || "Slider"; - - // For modes that show individual pairs, update the selected images if (["Slider", "Click", "Side-by-Side", "Stacked", "Onion Skin"].includes(mode)) { - const processedImages = []; - - if (this.imagesA[this.currentPairIndex]) { - processedImages.push(this.imagesA[this.currentPairIndex]); - } - if (this.imagesB[this.currentPairIndex]) { - processedImages.push(this.imagesB[this.currentPairIndex]); - } - - this.setSelected(processedImages); + const processed = []; + 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() { - if (this.animationFrame) { + if (this.animationFrame) clearInterval(this.animationFrame); - } - this.animationFrame = setInterval(() => { - if (this.currentPairIndex >= this.maxPairs - 1) { - // Loop back to the beginning - this.currentPairIndex = 0; - } else { - this.currentPairIndex++; - } + this.currentPairIndex = this.currentPairIndex >= this.maxPairs - 1 ? 0 : this.currentPairIndex + 1; this.updateSelectedPair(); this.node.setDirtyCanvas(true, false); }, this.autoPlaySpeed); } - - stopAutoPlay() { - if (this.animationFrame) { - clearInterval(this.animationFrame); - this.animationFrame = null; - } - } - - // Batch pagination methods - nextBatchPage() { - if (this.currentBatchPage < this.maxBatchPages - 1) { - this.currentBatchPage++; - this.updateNodeControls(); - this.node.setDirtyCanvas(true, false); - } - } - - previousBatchPage() { - if (this.currentBatchPage > 0) { - this.currentBatchPage--; - this.updateNodeControls(); - this.node.setDirtyCanvas(true, false); - } - } - - drawImageSideBySide(ctx, imageData, y, nodeWidth, availableHeight, imageIndex) { - if (!imageData || !imageData.img || !imageData.img.naturalWidth || !imageData.img.naturalHeight) { - return; - } - - const image = imageData.img; - const halfWidth = nodeWidth / 2; - const padding = 3; // Minimal padding from edges for maximum image space - const separatorWidth = 1; // Thin center separator - const usableWidth = halfWidth - padding - (separatorWidth / 2); - const usableHeight = availableHeight - (padding * 2); - - const imageAspect = image.naturalWidth / image.naturalHeight; - const usableAspect = usableWidth / usableHeight; - - let targetWidth, targetHeight; - - // Scale to fill most of the available half-space - if (imageAspect > usableAspect) { - targetWidth = usableWidth; - targetHeight = usableWidth / imageAspect; - } else { - targetHeight = usableHeight; - targetWidth = usableHeight * imageAspect; - } - - // Position images in their respective halves - const destX = imageIndex === 0 ? - padding + (usableWidth - targetWidth) / 2 : - halfWidth + (separatorWidth / 2) + padding + (usableWidth - targetWidth) / 2; - const destY = y + padding + (usableHeight - targetHeight) / 2; - - ctx.save(); - - // Clip to respective half to prevent overlap - const clipX = imageIndex === 0 ? 0 : halfWidth + (separatorWidth / 2); - const clipWidth = imageIndex === 0 ? halfWidth - (separatorWidth / 2) : halfWidth - (separatorWidth / 2); - ctx.beginPath(); - ctx.rect(clipX, y, clipWidth, availableHeight); - ctx.clip(); - - // Draw image - ctx.drawImage( - image, - 0, 0, image.naturalWidth, image.naturalHeight, - destX, destY, targetWidth, targetHeight - ); - - // Draw label - ctx.fillStyle = "rgba(0,0,0,0.8)"; - ctx.fillRect(destX, destY, 25, 18); - ctx.fillStyle = "white"; - ctx.font = "11px Arial"; - ctx.textAlign = "center"; - ctx.fillText(imageData.name, destX + 12, destY + 13); - - ctx.restore(); - - // Draw separator line (only once for the first image) - if (imageIndex === 0) { - ctx.save(); - ctx.beginPath(); - ctx.moveTo(halfWidth, y + padding); - ctx.lineTo(halfWidth, y + availableHeight - padding); - ctx.strokeStyle = "rgba(255,255,255,0.5)"; - ctx.lineWidth = 2; - ctx.stroke(); - ctx.restore(); - } - } - - drawImageStacked(ctx, imageData, y, nodeWidth, availableHeight, imageIndex) { - if (!imageData || !imageData.img || !imageData.img.naturalWidth || !imageData.img.naturalHeight) { - return; - } - - const image = imageData.img; - const halfHeight = availableHeight / 2; - const padding = 3; // Minimal padding from edges for maximum image space - const separatorHeight = 1; // Thin center separator - const usableWidth = nodeWidth - (padding * 2); - const usableHeight = halfHeight - padding - (separatorHeight / 2); - - const imageAspect = image.naturalWidth / image.naturalHeight; - const usableAspect = usableWidth / usableHeight; - - let targetWidth, targetHeight; - - // Scale to fill most of the available half-space - if (imageAspect > usableAspect) { - targetWidth = usableWidth; - targetHeight = usableWidth / imageAspect; - } else { - targetHeight = usableHeight; - targetWidth = usableHeight * imageAspect; - } - - const destX = padding + (usableWidth - targetWidth) / 2; - const destY = imageIndex === 0 ? - y + padding + (usableHeight - targetHeight) / 2 : - y + halfHeight + (separatorHeight / 2) + padding + (usableHeight - targetHeight) / 2; - - ctx.save(); - - // Clip to respective half to prevent overlap - const clipY = imageIndex === 0 ? y : y + halfHeight + (separatorHeight / 2); - const clipHeight = imageIndex === 0 ? halfHeight - (separatorHeight / 2) : halfHeight - (separatorHeight / 2); - ctx.beginPath(); - ctx.rect(0, clipY, nodeWidth, clipHeight); - ctx.clip(); - - // Draw image - ctx.drawImage( - image, - 0, 0, image.naturalWidth, image.naturalHeight, - destX, destY, targetWidth, targetHeight - ); - - // Draw label - ctx.fillStyle = "rgba(0,0,0,0.8)"; - ctx.fillRect(destX, destY, 25, 18); - ctx.fillStyle = "white"; - ctx.font = "11px Arial"; - ctx.textAlign = "center"; - ctx.fillText(imageData.name, destX + 12, destY + 13); - - ctx.restore(); - - // Draw separator line (only once for the first image) - if (imageIndex === 0) { - ctx.save(); - ctx.beginPath(); - ctx.moveTo(padding, y + halfHeight); - ctx.lineTo(nodeWidth - padding, y + halfHeight); - ctx.strokeStyle = "rgba(255,255,255,0.5)"; - ctx.lineWidth = 2; - ctx.stroke(); - ctx.restore(); - } - } - - drawOnionSkinMode(ctx, y, width, availableHeight) { - const opacity = this.node.properties?.onionSkinOpacity || 0.5; // Default to 50% opacity - - if (this.selected[0]) { - this.drawImage(ctx, this.selected[0], y, width, availableHeight); - } - - if (this.selected[1]) { - ctx.save(); - ctx.globalAlpha = opacity; - this.drawImage(ctx, this.selected[1], y, width, availableHeight); - ctx.restore(); - } - } - + stopAutoPlay() { if (this.animationFrame) { + clearInterval(this.animationFrame); + this.animationFrame = null; + } } computeSize(width) { const mode = this.node?.properties?.comparer_mode || "Slider"; - - // Base height calculation - extra generous sizing for prominent image display - let height = Math.max(500, width * 1.0); // Extra large for prominent image preview - - // Adjust height based on layout mode + let height = Math.max(500, width); switch (mode) { case "Stacked": - // Stacked mode needs more height to show both images vertically - height = Math.max(700, width * 1.5); // Extra large for prominent stacked viewing + height = Math.max(700, width * 1.5); break; case "Side-by-Side": - // Side-by-side needs generous height for both images - height = Math.max(500, width * 1.0); // Larger for prominent side-by-side viewing + height = Math.max(500, width); break; case "Grid": - // Grid mode needs more height to show multiple pairs - const pairs = Math.min(this.maxPairs || 1, 64); - const cols = Math.ceil(Math.sqrt(pairs * 2)); - const rows = Math.ceil((pairs * 2) / cols); - height = Math.max(500, (width / cols) * rows + 100); // Larger for better grid visibility + 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": - // Carousel mode uses generous height for prominent image display - height = Math.max(450, width * 0.9 + 100); // Larger for prominent carousel viewing + height = Math.max(450, width * 0.9 + 100); break; case "Batch": - // Batch mode shows multiple pairs vertically - const visiblePairs = Math.min(this.maxPairs || 1, 3); - height = Math.max(550, visiblePairs * (width * 0.6) + 100); // Larger for prominent batch viewing - break; - case "Onion Skin": - height = Math.max(400, width * 0.9); // Larger for prominent onion skin viewing + height = Math.max(550, Math.min(this.maxPairs || 1, 3) * (width * 0.6) + 100); break; default: - // Slider and Click modes use generous height for prominent display - height = Math.max(400, width * 0.9); // Larger for prominent image visibility + height = Math.max(400, width * 0.9); break; } - return [width, height]; } - mouse(event, pos, node) { - // Handle mouse events for the widget const mode = node.properties?.comparer_mode || "Slider"; - if (event.type === "pointermove") { node.pointerOverPos = [...pos]; - if (mode === "Slider") { + if (mode === "Slider") node.setDirtyCanvas(true, false); - } return true; } - - if (event.type === "pointerdown") { - // Handle clicks on grid cells for selection - if (mode === "Grid" && this.maxPairs > 1) { - const pairs = Math.min(this.maxPairs, 64); - const cols = Math.ceil(Math.sqrt(pairs * 2)); - const rows = Math.ceil((pairs * 2) / cols); - const cellWidth = node.size[0] / cols; - const widgetHeight = node.size[1] - this.y - 10; - const cellHeight = (widgetHeight - 40) / rows; // Reserve space for controls - - const col = Math.floor(pos[0] / cellWidth); - const row = Math.floor(pos[1] / cellHeight); - const cellIndex = row * cols + col; - - // Determine which image was clicked - let imageIndex = Math.floor(cellIndex / 2); - let isImageB = cellIndex % 2 === 1; - - if (imageIndex < this.maxPairs) { - // Switch to carousel mode to focus on this pair - this.currentPairIndex = imageIndex; - node.properties.comparer_mode = "Carousel"; + 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; - } + node.updateControlsVisibility(); + node.setDirtyCanvas(true, false); + return true; } } - return false; } - - // Cleanup method - onRemoved() { - this.stopAutoPlay(); - } + onRemoved() { this.stopAutoPlay(); } } - +// ============================ +// Extension Registration +// ============================ +// eslint-disable-next-line @typescript-eslint/no-explicit-any app.registerExtension({ name: "AdvancedImageComparer", - async beforeRegisterNodeDef(nodeType, nodeData, app) { - // Only process if this is our target node - if (nodeData.name !== "AdvancedImageComparer") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "AdvancedImageComparer") return; - } - - // Add properties - nodeType.prototype.properties = nodeType.prototype.properties || {}; - nodeType.prototype.properties.comparer_mode = "Slider"; - nodeType.prototype.properties.onionSkinOpacity = 0.5; // Default opacity for Onion Skin mode - - nodeType["@comparer_mode"] = { - type: "combo", - values: ["Slider", "Click", "Side-by-Side", "Stacked", "Grid", "Carousel", "Batch", "Onion Skin"], - }; - - // Store the original onDrawForeground function if it exists + 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; - - // Add our own onDrawForeground function for gradient background - nodeType.prototype.onDrawForeground = function(ctx) { - // Call the original onDrawForeground if it exists - if (origOnDrawForeground) { - origOnDrawForeground.apply(this, arguments); - } - - // Draw our custom gradient title + nodeType.prototype.onDrawForeground = function (ctx) { + if (origOnDrawForeground) + origOnDrawForeground.call(this, ctx); drawGradientTitle(this, ctx); }; - - // Clean up resources when node is removed const origOnRemoved = nodeType.prototype.onRemoved; - nodeType.prototype.onRemoved = function() { - if (origOnRemoved) { - origOnRemoved.apply(this, arguments); - } - - // Clear cached canvases to prevent memory leaks + nodeType.prototype.onRemoved = function () { + if (origOnRemoved) + origOnRemoved.call(this); CACHE.titleCanvas = null; CACHE.titleCtx = null; CACHE.collapsed.canvas = null; CACHE.collapsed.ctx = null; }; - - // Initialize state variables const onNodeCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = function() { - if (onNodeCreated) { - onNodeCreated.apply(this, arguments); - } - - // Ensure properties are properly initialized - this.properties = this.properties || {}; - if (!this.properties.comparer_mode) { - this.properties.comparer_mode = "Slider"; - } - + nodeType.prototype.onNodeCreated = function () { + 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; - - // Add layout control widget - this.layoutWidget = this.addWidget("combo", "Layout Mode", this.properties.comparer_mode, (value) => { - console.log(`[AdvancedImageComparer] Layout mode changed to: ${value}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const self = this; + self.layoutWidget = self.addWidget("combo", "Layout Mode", this.properties.comparer_mode, (value) => { this.properties.comparer_mode = value; - - // Show/hide controls based on mode this.updateControlsVisibility(); - - this.setDirtyCanvas(true, false); - }, { - values: ["Slider", "Click", "Side-by-Side", "Stacked", "Grid", "Carousel", "Batch", "Onion Skin"] - }); - - // Add batch selector widget for modes that need it - this.batchSelectorWidget = this.addWidget("combo", "View Pair", "1", (value) => { - console.log("[AdvancedImageComparer] Batch selector changed to:", value); - const pairIndex = parseInt(value) - 1; - if (this.comparerWidget && pairIndex >= 0 && pairIndex < this.comparerWidget.maxPairs) { - this.comparerWidget.currentPairIndex = pairIndex; + 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) => { + const idx = parseInt(value) - 1; + if (this.comparerWidget && idx >= 0 && idx < this.comparerWidget.maxPairs) { + this.comparerWidget.currentPairIndex = idx; this.comparerWidget.updateSelectedPair(); - this.setDirtyCanvas(true, false); + self.setDirtyCanvas(true, false); } - }, { - values: ["1"] + }, { 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"; }); - - // Add carousel control widgets (initially hidden) - this.prevButton = this.addWidget("button", "◀ Previous", null, () => { - if (this.comparerWidget) { - this.comparerWidget.previousPair(); - } - }); - - this.nextButton = this.addWidget("button", "Next ▶", null, () => { - if (this.comparerWidget) { - this.comparerWidget.nextPair(); - } - }); - - this.autoPlayButton = this.addWidget("button", "▶ Play", null, () => { - if (this.comparerWidget) { - this.comparerWidget.toggleAutoPlay(); - // Update button text - this.autoPlayButton.name = this.comparerWidget.autoPlayEnabled ? "⏸ Pause" : "▶ Play"; - } - }); - - this.pairInfoWidget = this.addWidget("text", "Pair Info", "1 / 1", () => {}, {}); - this.pairInfoWidget.disabled = true; - - // Add batch pagination control widgets (initially hidden) - this.batchPrevButton = this.addWidget("button", "◀ Prev Page", null, () => { - if (this.comparerWidget) { - this.comparerWidget.previousBatchPage(); - } - }); - - this.batchNextButton = this.addWidget("button", "Next Page ▶", null, () => { - if (this.comparerWidget) { - this.comparerWidget.nextBatchPage(); - } - }); - - this.batchPageInfoWidget = this.addWidget("text", "Page Info", "Page 1 / 1", () => {}, {}); - this.batchPageInfoWidget.disabled = true; - - // Add Onion Skin opacity slider (initially hidden) - this.onionSkinOpacitySlider = this.addWidget("slider", "Opacity B", this.properties.onionSkinOpacity, (value) => { - this.properties.onionSkinOpacity = parseFloat(value); - this.setDirtyCanvas(true, false); - }, { - min: 0.0, - max: 1.0, - step: 0.01 - }); - - // Add cache management buttons for debugging - this.clearCacheButton = this.addWidget("button", "Clear Cache", null, () => { - // Call the Python class method to clear cache + 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) => { + 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"); - // We can't directly call Python methods from JS, but we can log this action - // The cache clearing happens automatically in the Python code }); - this.clearCacheButton.hidden = true; // Hide by default, can be shown for debugging - - // Create the custom widget - this.comparerWidget = this.addCustomWidget(new AdvancedImageComparerWidget("advanced_comparer", this)); - - // Initialize controls visibility + self.clearCacheButton.hidden = true; + this.comparerWidget = self.addCustomWidget(new AdvancedImageComparerWidget("advanced_comparer", this)); this.updateControlsVisibility(); - - // Set an extra large initial size optimized for prominent image display - // Use a very large default size that provides ample space for image previews - this.setSize([700, 600]); // Extra large initial size for prominent image display - this.setDirtyCanvas(true, true); + self.setSize([700, 600]); + self.setDirtyCanvas(true, true); }; - - // Method to show/hide controls based on mode - nodeType.prototype.updateControlsVisibility = function() { + nodeType.prototype.updateControlsVisibility = function () { const mode = this.properties.comparer_mode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const self = this; const hasMultiplePairs = this.comparerWidget && this.comparerWidget.maxPairs > 1; - - // Show batch selector for modes that need individual pair selection const showBatchSelector = hasMultiplePairs && ["Slider", "Click", "Side-by-Side", "Stacked", "Onion Skin"].includes(mode); - - // Show carousel controls for carousel mode - always show if mode is Carousel (even with single pair for consistency) const showCarouselControls = mode === "Carousel"; - - // Show batch pagination controls for batch mode with multiple pages const showBatchPagination = mode === "Batch" && this.comparerWidget && this.comparerWidget.maxBatchPages > 1; - - // Show Onion Skin opacity slider for Onion Skin mode const showOnionSkinSlider = mode === "Onion Skin"; - - console.log(`[AdvancedImageComparer] updateControlsVisibility: mode=${mode}, hasMultiplePairs=${hasMultiplePairs}, showCarouselControls=${showCarouselControls}`); - - // Update batch selector - if (this.batchSelectorWidget) { - this.batchSelectorWidget.hidden = !showBatchSelector; + if (self.batchSelectorWidget) { + self.batchSelectorWidget.hidden = !showBatchSelector; if (showBatchSelector && this.comparerWidget) { - // Update the options for the batch selector - const options = []; - for (let i = 1; i <= this.comparerWidget.maxPairs; i++) { - options.push(i.toString()); - } - this.batchSelectorWidget.options.values = options; - this.batchSelectorWidget.value = (this.comparerWidget.currentPairIndex + 1).toString(); + 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(); } } - - // Update carousel controls - if (this.prevButton) { - this.prevButton.hidden = !showCarouselControls; + 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 (this.nextButton) { - this.nextButton.hidden = !showCarouselControls; + 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 (this.autoPlayButton) { - this.autoPlayButton.hidden = !showCarouselControls; - } - if (this.pairInfoWidget) { - this.pairInfoWidget.hidden = !showCarouselControls; - if (showCarouselControls && this.comparerWidget) { - this.pairInfoWidget.value = `${this.comparerWidget.currentPairIndex + 1} / ${this.comparerWidget.maxPairs}`; - } - } - - // Update batch pagination controls - if (this.batchPrevButton) { - this.batchPrevButton.hidden = !showBatchPagination; - } - if (this.batchNextButton) { - this.batchNextButton.hidden = !showBatchPagination; - } - if (this.batchPageInfoWidget) { - this.batchPageInfoWidget.hidden = !showBatchPagination; - if (showBatchPagination && this.comparerWidget) { - this.batchPageInfoWidget.value = `Page ${this.comparerWidget.currentBatchPage + 1} / ${this.comparerWidget.maxBatchPages}`; - } - } - - // Update Onion Skin opacity slider - if (this.onionSkinOpacitySlider) { - this.onionSkinOpacitySlider.hidden = !showOnionSkinSlider; - if (showOnionSkinSlider) { - this.onionSkinOpacitySlider.value = this.properties.onionSkinOpacity; - } + if (self.onionSkinOpacitySlider) { + self.onionSkinOpacitySlider.hidden = !showOnionSkinSlider; + if (showOnionSkinSlider) + self.onionSkinOpacitySlider.value = this.properties.onionSkinOpacity; } }; - - // Override computeSize to account for the widget const originalComputeSize = nodeType.prototype.computeSize; - nodeType.prototype.computeSize = function(out) { - const size = originalComputeSize ? originalComputeSize.apply(this, arguments) : [700, 600]; // Extra large defaults for prominent image display + nodeType.prototype.computeSize = function (out) { + const size = originalComputeSize ? originalComputeSize.call(this, out) : [700, 600]; if (this.comparerWidget) { const widgetSize = this.comparerWidget.computeSize(size[0]); - - // Calculate additional space needed for controls - increased padding for better layout - let extraHeight = 60; // Increased base padding for layout widget + title bar - + let extra = 60; const mode = this.properties.comparer_mode; const hasMultiplePairs = this.comparerWidget.maxPairs > 1; - - if (mode === "Carousel") { - // Always show carousel controls when in carousel mode - extraHeight += 120; // Restored height for carousel controls - } else if (["Slider", "Click", "Side-by-Side", "Stacked"].includes(mode) && hasMultiplePairs) { - extraHeight += 35; // Increased for batch selector - } else if (mode === "Batch" && this.comparerWidget.maxBatchPages > 1) { - extraHeight += 90; // Restored for batch pagination - } else if (mode === "Onion Skin") { - extraHeight += 35; // Increased for opacity slider - if (hasMultiplePairs) { - extraHeight += 35; // Increased for batch selector - } + 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] + extraHeight); + size[1] = Math.max(size[1], widgetSize[1] + extra); } return size; }; - - // Override onExecuted to handle image data - this should be called when the node executes const originalOnExecuted = nodeType.prototype.onExecuted; - nodeType.prototype.onExecuted = function(message) { - // Call the original onExecuted first (this handles the standard PreviewImage functionality) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeType.prototype.onExecuted = function (message) { let result; - if (originalOnExecuted) { - result = originalOnExecuted.apply(this, arguments); - } - - // Now handle our custom logic + if (originalOnExecuted) + result = originalOnExecuted.call(this, message); if (message && typeof message === 'object') { - - // Check for images in different possible locations - let images = null; - if (message.ui && message.ui.images && Array.isArray(message.ui.images)) { - images = message.ui.images; - } else if (message.images && Array.isArray(message.images)) { - images = message.images; - } - - if (images && images.length > 0) { - console.log(`[AdvancedImageComparer] Received ${images.length} images`); - - if (this.comparerWidget) { - this.comparerWidget.value = { images: images }; - - // Ensure the node is large enough for prominent image display - be very aggressive - const currentSize = this.size; - const minWidth = 700; // Even larger minimum for prominent image display - const minHeight = 600; // Even larger minimum for prominent image display - - // Always ensure adequate size for prominent display - const newSize = [ - Math.max(currentSize[0], minWidth), - Math.max(currentSize[1], minHeight) - ]; - - // Always apply the sizing to ensure prominence - immediately - console.log(`[AdvancedImageComparer] Ensuring prominent display size from [${currentSize[0]}, ${currentSize[1]}] to [${newSize[0]}, ${newSize[1]}]`); - this.setSize(newSize); - - // Force immediate complete layout refresh - this.setDirtyCanvas(true, true); - - // Force additional delayed refresh to ensure proper sizing - setTimeout(() => { - this.setDirtyCanvas(true, true); - }, 100); - - this.setDirtyCanvas(true, false); - } else { - console.error("[AdvancedImageComparer] No comparerWidget found on node!"); - } - } else { - console.log("[AdvancedImageComparer] No images received in message"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const self = this; + 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; }; - - // Mouse event handlers - nodeType.prototype.setIsPointerDown = function(down = this.isPointerDown) { - const newIsDown = down && !!app.canvas.pointer_is_down; + nodeType.prototype.setIsPointerDown = function (down = this.isPointerDown) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const self = this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const appObj = app; + const newIsDown = down && !!appObj.canvas.pointer_is_down; if (this.isPointerDown !== newIsDown) { this.isPointerDown = newIsDown; - this.setDirtyCanvas(true, false); + self.setDirtyCanvas(true, false); } this.imageIndex = this.isPointerDown ? 1 : 0; - - if (this.isPointerDown) { - requestAnimationFrame(() => { - this.setIsPointerDown(); - }); - } + if (this.isPointerDown) + requestAnimationFrame(() => { this.setIsPointerDown(); }); }; - - nodeType.prototype.onMouseDown = function(event, pos, canvas) { - this.setIsPointerDown(true); - return false; - }; - - nodeType.prototype.onMouseEnter = function(event) { - this.setIsPointerDown(!!app.canvas.pointer_is_down); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeType.prototype.onMouseDown = function () { this.setIsPointerDown(true); return false; }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeType.prototype.onMouseEnter = function () { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const appObj = app; + this.setIsPointerDown(!!appObj.canvas.pointer_is_down); + // eslint-disable-next-line @typescript-eslint/no-explicit-any this.isPointerOver = true; this.setDirtyCanvas(true, false); }; - - nodeType.prototype.onMouseLeave = function(event) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeType.prototype.onMouseLeave = function () { + // eslint-disable-next-line @typescript-eslint/no-explicit-any this.setIsPointerDown(false); this.isPointerOver = false; this.setDirtyCanvas(true, false); }; - - nodeType.prototype.onMouseMove = function(event, pos, canvas) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeType.prototype.onMouseMove = function (_event, pos) { this.pointerOverPos = [...pos]; - - const mode = this.properties.comparer_mode || "Slider"; - - switch (mode) { - case "Slider": - this.setDirtyCanvas(true, false); - break; - case "Click": - this.imageIndex = this.pointerOverPos[0] > this.size[0] / 2 ? 1 : 0; - break; - case "Side-by-Side": - case "Stacked": - // No special mouse handling needed for these modes - break; - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (this.properties.comparer_mode === "Slider") + this.setDirtyCanvas(true, false); + else if (this.properties.comparer_mode === "Click") + this.imageIndex = pos[0] > this.size[0] / 2 ? 1 : 0; return true; }; - - // Add context menu options - const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; - nodeType.prototype.getExtraMenuOptions = function(_, options) { - if (getExtraMenuOptions) { - getExtraMenuOptions.apply(this, arguments); - } - - const layoutModes = ["Slider", "Click", "Side-by-Side", "Stacked", "Grid", "Carousel", "Batch", "Onion Skin"]; - const currentMode = this.properties.comparer_mode || "Slider"; - - // Add separator for layout modes section - options.push(null); - - // Add auto-fill toggle - options.push({ - content: "Toggle Auto Fill Empty Slot", - callback: () => { - // Toggle the auto_fill widget value by finding it in widgets - const autoFillWidget = this.widgets?.find(w => w.name === "auto_fill"); - if (autoFillWidget) { - autoFillWidget.value = !autoFillWidget.value; - console.log("[AdvancedImageComparer] Auto-fill toggled via menu:", autoFillWidget.value); - } - } - }); - - options.push(null); // separator - - // Add main layout modes submenu - const layoutSubmenu = []; - - layoutModes.forEach(mode => { - layoutSubmenu.push({ - content: `${mode === currentMode ? "✓ " : ""}${mode}`, - callback: () => { - console.log(`[AdvancedImageComparer] Context menu mode change to: ${mode}`); - this.properties.comparer_mode = mode; - if (this.layoutWidget) { - this.layoutWidget.value = mode; - } - this.updateControlsVisibility(); - this.setDirtyCanvas(true, false); - } - }); - }); - - options.push({ - content: "Layout Mode", - submenu: { - options: layoutSubmenu - } - }); - - // Add quick access to most common modes - options.push(null); // separator - - const quickModes = ["Slider", "Side-by-Side", "Grid", "Carousel"]; - quickModes.forEach(mode => { - if (mode !== currentMode) { - options.push({ - content: `Switch to ${mode}`, - callback: () => { - console.log(`[AdvancedImageComparer] Quick switch to: ${mode}`); - this.properties.comparer_mode = mode; - if (this.layoutWidget) { - this.layoutWidget.value = mode; - } - this.updateControlsVisibility(); - this.setDirtyCanvas(true, false); - } - }); - } - }); - - // Add Select Pair submenu if applicable - if (this.comparerWidget && this.comparerWidget.maxPairs > 1 && - ["Slider", "Click", "Side-by-Side", "Stacked", "Onion Skin"].includes(currentMode)) { - - options.push(null); // separator - const pairSubmenu = []; - for (let i = 0; i < this.comparerWidget.maxPairs; i++) { - pairSubmenu.push({ - content: `${i === this.comparerWidget.currentPairIndex ? "✓ " : ""}Pair ${i + 1}`, - callback: () => { - if (this.comparerWidget) { - this.comparerWidget.currentPairIndex = i; - this.comparerWidget.updateSelectedPair(); - this.setDirtyCanvas(true, false); - } - } - }); - } - options.push({ - content: "Select Pair", - submenu: { - options: pairSubmenu - } - }); - } - - options.push(null); // separator - - options.push( - { - content: "Reset to Default Size", - callback: () => { - // Reset to extra large default size optimized for prominent image display - this.setSize([700, 600]); - this.setDirtyCanvas(true, false); - } - } - ); - }; - - console.log("AdvancedImageComparer node setup complete with auto-fill functionality"); + console.log("AdvancedImageComparer node setup complete"); } -}); \ No newline at end of file +}); +//# sourceMappingURL=advanced_comparer.js.map \ No newline at end of file diff --git a/web/gradient_title.js b/web/gradient_title.js index 4bda1a1..18fa2ea 100644 --- a/web/gradient_title.js +++ b/web/gradient_title.js @@ -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(); -} \ No newline at end of file +} +//# sourceMappingURL=gradient_title.js.map \ No newline at end of file diff --git a/web/matrix_button.js b/web/matrix_button.js index c65b510..98d1f53 100644 --- a/web/matrix_button.js +++ b/web/matrix_button.js @@ -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 = ` + + + + +
+ +
+

The Shader Matrix

+

Harnessing Noise with Shader Algorithms for Image Generation

+
+ + + +
+
+

🔮 Introduction ✨ The Essence of Noise

+

ShaderNoiseKSampler is a ComfyUI node that uses shader algorithms to generate and control procedural noise. This noise, refeered to as shader noise, can then be used to influence the AI image creation process in latent diffusion models. The aim is to provide a way to introduce structured mathematical patterns into the diffusion process, offering a different approach to guiding image generation.

+

This document outlines the features, technical details, and underlying ideas of ShaderNoiseKSampler, showing how different shader noise types can create various visual characteristics that influence and drive the diffusion process.

+ +
+ +
+ +
+

💠 The Path of Noise

+

Noise is fundamentally a mathematical expression of ordered chaos—a seemingly random pattern that nonetheless follows precise mathematical rules. In the context of image generation, these noise patterns serve as the seed from which creation emerges. ShaderNoiseKSampler allows artists to shape this primordial mathematical chaos according to sacred geometric principles, guiding the diffusion model\'s sampling process along specific aesthetic trajectories.

+

Just as ancient civilizations recognized patterns in nature and encoded them in their art and architecture, ShaderNoiseKSampler enables digital artists to encode mathematical archetypes into the generative process, creating a bridge between abstract mathematics and visual expression.

+
+
+ +
+ +
+

🐜 Project Genesis ∞The Birth of Order from Chaos

+ +
+

👁️ The Vision Unveiled

+

The genesis of ShaderNoiseKSampler lies in a contemplation of nature\'s fundamental paradox: the inherent order within apparent chaos. Observing the natural world reveals that seemingly random phenomena—the flicker of fire, the branching of lightning, the flow of water, the structure of terrestrial forms—all exhibit underlying fractal patterns. This ubiquitous principle, that profound order underpins apparent disorder, became a guiding inspiration.

+

The initial conceptual spark was hyperspace—an unseen realm between dimensions. While seemingly chaotic in its construct, closer examination reveals a perfect, almost unimaginable mathematical precision and scope. It is this notion that the unseen can be rendered observable with the appropriate tools which led to a pivotal question: Could a method be devised to introduce deliberate order into random noise? Could one inject structure into the latent space itself, thereby crystallizing defined pathways for creative generation?

+
+ +
+

🏗️ The Ant Colony Revelation

+

A compelling metaphor emerged from an unexpected source: the art of ant sculpture. This process, which captures the hidden architecture of ant tunnels by filling them with molten metal, offered a tangible analogy. Once cooled and excavated, the metal cast reveals a previously unseen, complex structure—a physical manifestation of the colony\'s hidden pathways. The question then became: how could one create a virtual equivalent of this molten metal? The answer was clear: Shaders.

+ +
+
  • Virtual Molten Metal (Shaders) = Shader Noise
    Shader patterns, like molten metal, flow into and define the pathways within latent space.
  • +
  • Solidification = Sampling Process
    The K-sampling process allows this structured noise to effectively \'solidify\' within the latent dimensions.
  • +
  • Excavation = Denoising
    As the model denoises, it progressively reveals the crystallized patterns embedded by the shader.
  • +
  • Revealed Sculpture = Final Image
    The output is a manifestation of a previously unseen space, a navigable pathway forged through the realm of possibility.
  • +
    +
    +
    +

    ⚗️ The Alchemical Process: From Theory to Practice

    +

    The journey from concept to a functional implementation involved extensive experimentation, iterative debugging, and countless visualization tests. The challenge was multifaceted, encompassing not only technical hurdles but also philosophical considerations: How could the abstract vision of controlled chaos be translated into robust code? Critically, how could novel structures be introduced without disrupting the model\'s foundational training?

    +

    A crucial breakthrough was the understanding that models cannot be expected to produce coherent results when fed noise types entirely alien to their training data. This led to the principle of augmentation rather than replacement. The final noise input to the KSampler is therefore not a wholesale substitution but a careful modification of the standard base noise. The shader noise augments and sculpts this base noise, and the influence of these shader-defined patterns began to yield compelling and controllable results in the generated outputs.

    +

    This hybrid methodology—a delicate equilibrium between the familiar (the model\'s learned representations) and the novel (the intentionally introduced structure)—fosters both generative stability and exploratory capacity.

    +
    +
    The Blend: A Cornerstone Principle
    +

    Respecting a model\'s training by blending shader-generated noise with the base noise—as opposed to outright replacement—is a cornerstone of ShaderNoiseKSampler. This approach preserves generative coherence while enabling nuanced, controllable structural influence.

    +
    +
    + "Not to replace the chaos, but to give it direction
    + Not to eliminate randomness, but to make it purposeful
    + To transform noise into navigation, and navigation into art"
    + — The Genesis Vision +
    +
    + +
    +

    🗺️ Exploring Latent Space: A Developing Idea

    +

    The exploration extended to the nature of latent space. It became apparent that by adjusting shader noise parameters with a consistent seed, one could systematically alter the generative output. This suggested a way to modify not just noise characteristics, but potentially the "topology" of the latent space being explored.

    +

    This led to an interesting mode of exploration. It was observed that different seeds seemed to contain unique thematic elements—recurring characters, color palettes, objects, and styles—that could be traced and explored. ShaderNoiseKSampler evolved from a noise tool into a means of navigating within a model's latent space with more specific control. This helped clarify the tool's potential.

    +

    Each parameter adjustment can be seen as a step along conceptual pathways, each blend mode a different way to perceive and traverse the possibilities.

    + +
    + +
    + +
    + +
    +

    🌌 Core Concept △ The Alchemical Transformation

    +

    ShaderNoiseKSampler implements the formula Lt=Sα(N)∘Kβ(t)—a mathematical invocation where structured noise Sα transforms the base noise N before it enters the diffusion sampling process Kβ. This elegant formula captures the essence of guided chaos, where randomness is structured according to sacred patterns before manifesting in the final image.

    + +
    +
    The Mathematics of Transformation
    +

    The core equation Lt=Sα(N)∘Kβ(t) can be understood as follows:

    +
    + Lt = Sα(N) ∘ Kβ(t) +
    +

    Where:

    +
      +
    • Lt: The final latent representation at timestep t
    • +
    • Sα(N): The shader transformation with parameters α applied to base noise N
    • +
    • Kβ(t): The diffusion sampling process with parameters β at timestep t
    • +
    • ∘: Function composition operator (application of one function to the result of another)
    • +
    +

    This mathematical formulation allows for more precise control over the noise structures that guide the generative process, creating a harmony between randomness and order.

    +
    + +
    +

    ⚙️ The Role of ShaderToTensor: Bridging Code and Concept

    +

    The ShaderToTensor class, implemented in shader_to_tensor.py, is the cornerstone of this system\'s ability to translate abstract shader concepts into concrete PyTorch tensors. It acts as the alchemical crucible where mathematical descriptions of noise are transmuted into the actual structured noise (Sα in our formula) that the KSampler can utilize.

    +

    Key functions of ShaderToTensor include:

    +
      +
    • Noise Synthesis: It contains the Python and PyTorch implementations of various noise algorithms (Perlin, Cellular, Curl, etc.). When you select a \'Shader Noise Type\' in the UI, you\'re choosing a specific method within this class.
    • +
    • Parameter Interpretation: It takes the parameters you set (Scale, Octaves, Warp Strength, etc.) and uses them to control the generation of these noise tensors.
    • +
    • Tensor Formatting: It ensures the generated noise is in the correct format (shape, data type, device) to be compatible with the ComfyUI latent workflow and the KSampler. This includes handling channel expansion (e.g., from 1-channel noise to 4 or 9 channels for the latent space).
    • +
    • Temporal Coherence Logic: For animated noise, ShaderToTensor (often in conjunction with specialized generator classes) implements the logic to produce evolving noise patterns over a time dimension.
    • +
    +

    Essentially, ShaderToTensor is the engine that powers the Sα(N) part of the equation. Without it, the conceptual shader patterns would remain abstract; this class makes them tangible and usable by the diffusion model.

    +
    + +
    +

    📊 Essential Parameters

    +
    +
    +
    Shader Noise Type
    +

    The fundamental pattern archetype (tensor_field, curl_noise, domain_warp, etc.)

    +
    +
    +
    Noise Scale
    +

    Controls the frequency of pattern repetition (1.0 is the harmonic baseline)

    +
    +
    +
    Noise Octaves
    +

    The number of recursive self-similar layers (follows Fibonacci principles)

    +
    +
    +
    Noise Warp Strength
    +

    Controls the amount of displacement or distortion applied to the noise coordinates, altering the pattern's structure.

    +
    +
    +
    Noise Phase Shift
    +

    Adjusts the input to the noise function, effectively shifting the generated pattern. This can be used to explore variations of the noise.

    +
    +
    +
    Shape Mask Type
    +

    The geometric form overlaid upon the noise (radial, spiral, hexgrid, etc.)

    +
    +
    +
    Shape Mask Strength
    +

    Controls the intensity of the shape mask, determining how much of the noise is affected by the mask.

    +
    +
    +
    Noise Transformation
    +

    Mathematical operations (e.g., absolute, sin, sqrt) applied to the shader noise before blending. [Recommended: Experiment freely. See 'Noise Transformations' section for details].

    +
    +
    +
    Blend Mode
    +

    The alchemical method of combining shader noise with base noise (multiply, add, etc.). [Recommended: Multiply, Normal, Screen, Overlay].

    +
    +
    +
    Color Intensity
    +

    Adjusts the impact of the selected color scheme on the noise pattern.

    +
    +
    +
    Sequential Stages
    +

    Number of sequential shader stages to apply before injection stages. Each stage can have varied strength and apply noise over a specific portion of the diffusion steps. [Recommended: 1 - 3].

    +
    +
    +
    Injection Stages
    +

    Number of injection shader stages to apply after sequential stages. These stages typically inject noise at specific steps within the diffusion process, allowing for targeted interventions. [Recommended: 0 - 2].

    +
    +
    +
    Shader Strength
    +

    Overall strength of the shader noise influence for all stage types. Set to 0.0 to disable shader noise and use only the base noise. This acts as a global multiplier for stage-specific strengths. [Recommended: 0.1 - 0.4].

    +
    +
    +
    CFG
    +

    Classifier-Free Guidance scale. [Recommended: 6 - 9].

    +
    +
    +
    Steps
    +

    Number of sampling iterations. [Recommended: 20 - 60].

    +
    +
    +
    Sampler & Scheduler Compatibility
    +
    +
    Sampler
    +
    Compatibility
    +
    Notes
    + +
    Euler_Ancestral
    +
    High
    +
    Excellent across all model types
    + +
    dpm_2_ancestral
    +
    High
    +
    Strong performance with all models
    + +
    dpmppm_2_ancestral
    +
    High
    +
    Reliable for all model variants
    + +
    LCM
    +
    High
    +
    Consistent results across models
    + +
    Scheduler
    +
    Compatibility
    +
    Notes
    + +
    Beta
    +
    High
    +
    Recommended scheduler
    + +
    Normal
    +
    High
    +
    Good general performance
    + +
    Simple
    +
    High
    +
    Effective for most cases
    + +
    kl_optimal
    +
    High
    +
    Optimized performance
    + +
    +
    +
    +
    +
    + +
    + +
    +

    🏵️ Shader Noise Patterns ✧ The Twelve Noise Archetypes

    +

    ShaderNoiseKSampler harnesses twelve fundamental shader noise archetypes, each channeling a different aspect of mathematical reality. These patterns are the building blocks of visual coherence, from the flowing whorls of Perlin noise to the cosmic web-like structures of Tensor Fields. Each archetype generates its own distinct style of noise, offering a diverse palette for visual expression.

    + +
    +
    +
    +
    Tensor Field
    +
    Manifests flow patterns based on tensor mathematics, revealing the underlying force vectors of the mathematical space.
    +
    +
    +
    +
    Cellular
    +
    Creates organic, cell-like structures through Voronoi diagrams, mimicking natural growth patterns.
    +
    Support me on Ko-fi
    +
    +
    +
    +
    Domain Warp
    +
    Applies non-linear distortions to space itself, creating fluid-like transformations of the underlying pattern.
    +
    +
    +
    +
    Fractal
    +
    Embodies self-similarity across scales, reflecting the infinite recursive patterns found throughout nature.
    +
    Support me on Ko-fi
    +
    +
    +
    +
    Perlin
    +
    The classic gradient noise that creates smooth, natural-looking transitions and flowing textures.
    +
    Support me on Ko-fi
    +
    +
    +
    +
    Waves
    +
    Generates harmonic oscillations that combine to form complex interference patterns.
    +
    Support me on Ko-fi
    +
    +
    +
    +
    Gaussian
    +
    Pure probabilistic noise based on the normal distribution, the foundation of natural randomness.
    +
    Support me on Ko-fi
    +
    +
    +
    +
    Heterogeneous FBM
    +
    Varies the fractal dimension across space, creating regions of different turbulence and detail.
    +
    Support me on Ko-fi
    +
    +
    +
    +
    Interference
    +
    Simulates wave interaction patterns, creating complex nodal structures through phase relationships.
    +
    Support me on Ko-fi
    +
    +
    +
    +
    Spectral
    +
    Controls the frequency spectrum directly, allowing precise frequency band manipulation.
    +
    Support me on Ko-fi
    +
    +
    +
    +
    3D Projection
    +
    Projects three-dimensional noise onto a 2D plane, creating depth and volumetric effects.
    +
    Support me on Ko-fi
    +
    +
    +
    +
    Curl Noise
    +
    Generates divergence-free vector fields that create perfect fluid-like flow patterns.
    +
    +
    +
    + +
    +

    🌟 Unlock Exclusive Shader Noise Palettes!

    +

    Become a valued member to access an expanded library of unique shader noise archetypes, advanced shader features, and custom ComfyUI tools. Your support helps fuel further development!

    +
    + + + +
    + +
    +

    ⚛️ Noise Mathematics 📐 The Underlying Formulations

    +

    Each shader noise archetype in ShaderNoiseKSampler is built upon rigorous mathematical foundations. Understanding these mathematical principles reveals the sacred geometry inherent in these patterns and allows for more intentional application in the creative process.

    + +
    + + + + + + + + + + + + +
    + +
    +

    Perlin Noise Mathematics

    +

    Perlin noise, developed by Ken Perlin in 1983, uses a grid of random gradient vectors with interpolation to create smooth, natural-looking noise. The core computation involves:

    +
    + n(x,y) = ∑ ωi · gi · ((x,y) - (xi,yi)) +
    +

    Where gi are random gradient vectors at grid points, and ωi are interpolation weights. The smoothstep function provides the basis for the interpolation:

    +
    + smoothstep(t) = t2(3 - 2t) +
    +

    This smooth interpolation creates the characteristic flowing appearance of Perlin noise, making it ideal for natural phenomena like terrain, clouds, and flowing water.

    +
    +
    + Perlin Noise Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Cellular Noise Mathematics

    +

    Cellular noise (also known as Worley noise) creates Voronoi-like patterns based on distance metrics to feature points:

    +
    + Fn(x) = nth min{dist(x, xi) | i ∈ feature points} +
    +

    Where dist() is a distance function (typically Euclidean) and Fn returns the distance to the nth closest feature point. Various cellular patterns emerge by combining these distances:

    +
    + F2 - F1 : cell edges
    + F1 : cell interiors
    + 2F1 - F2 : cracks +
    +

    These combinations create patterns reminiscent of cellular structures, bubbles, and organic tissues.

    +
    +
    + Cellular Noise Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Tensor Field Mathematics

    +

    Tensor fields represent directional information at every point in space, creating flow-like patterns. The core mathematics involves computing the eigenvalues λ and eigenvectors v of tensor matrices:

    +
    + T(x,y) = + \\begin{pmatrix} + T_{xx} & T_{xy} \\\\ + T_{xy} & T_{yy} + \\end{pmatrix} +
    +

    The eigenvalues and eigenvectors are found by solving:

    +
    + T·v = λ·v +
    +

    Different visualizations emerge based on how we render these tensor properties:

    +
      +
    • Eigenvalue visualization: Shows magnitude of deformation
    • +
    • Streamlines: Shows direction of principal stress
    • +
    • Hyperstreamlines: Combines both with weighted influence
    • +
    • Ellipses: Represents the tensor as oriented ellipses
    • +
    +
    +
    + Tensor Field Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Curl Noise Mathematics

    +

    Curl noise generates divergence-free vector fields, perfect for fluid-like motions. It\'s based on the curl operator from vector calculus:

    +
    + ∇ × Ψ = (∂Ψz/∂y - ∂Ψy/∂z, ∂Ψx/∂z - ∂Ψz/∂x, ∂Ψy/∂x - ∂Ψx/∂y) +
    +

    In 2D, this simplifies to a scalar field where the curl is perpendicular to the plane:

    +
    + curl(Ψ)(x,y) = ∂Ψy/∂x - ∂Ψx/∂y +
    +

    This mathematical property ensures the resulting vector field has zero divergence (∇·v = 0), creating perfect flow patterns without sources or sinks.

    +
    +
    + Curl Noise Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Domain Warp Mathematics

    +

    Domain warping is a technique where the input coordinates (the domain) of a noise function are displaced or distorted by another noise function. This creates swirling, turbulent, or flowing effects in the final pattern. Instead of sampling noise at point P, we sample at P + offset(P), where offset(P) is itself a noise function.

    +
    + Noisefinal(P) = Noisebase(P + Swarp × Noisedisplacement(P × Fwarp)) +
    +

    Where:

    +
      +
    • P: The input coordinate (e.g., (x,y)).
    • +
    • Noisebase: The primary noise function (e.g., Perlin, Simplex).
    • +
    • Noisedisplacement: A noise function generating the offset vectors.
    • +
    • Swarp: Warp strength, controlling the magnitude of distortion.
    • +
    • Fwarp: Warp frequency, controlling the detail of the distortion.
    • +
    +

    By applying this recursively (warping the domain of the displacement noise itself), more complex fractal warping effects can be achieved.

    +
    +
    + Domain Warp Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Fractal Noise (FBM) Mathematics

    +

    Fractal noise, often implemented as Fractal Brownian Motion (FBM), is a fundamental technique for generating natural-looking textures. It is constructed by summing multiple layers (octaves) of a base noise function (like Perlin or Simplex). Each successive octave has a higher frequency and a lower amplitude.

    +
    + FBM(P) = ∑N-1i=0 Ai × Noise(Fi × P) +
    +

    Where:

    +
      +
    • P: The input coordinate.
    • +
    • N: The number of octaves (layers of detail).
    • +
    • Noise: The base coherent noise function (e.g., Perlin).
    • +
    • A: Amplitude factor (persistence, typically < 1, e.g., 0.5). Controls how much amplitude decreases per octave.
    • +
    • F: Frequency factor (lacunarity, typically > 1, e.g., 2.0). Controls how much frequency increases per octave.
    • +
    +

    The summation creates a pattern that exhibits self-similarity across different scales, characteristic of many natural phenomena.

    +
    +
    + Fractal Noise (FBM) Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Waves Noise Mathematics

    +

    Waves noise is typically generated by summing multiple sine or cosine wave functions. Each wave can have its own amplitude, frequency, phase, and direction. The superposition of these waves can create a wide variety_of_patterns, from simple ripples to complex interference effects.

    +
    + Waves(P) = ∑i Ai × sin(ki ⋅ P + ωit + φi) +
    +

    Where for each wave i:

    +
      +
    • P: The input coordinate (e.g., (x,y)).
    • +
    • Ai: Amplitude of the wave.
    • +
    • ki: Wave vector (determines direction and spatial frequency/wavelength). Its magnitude |ki| = 2π / λi.
    • +
    • ωi: Angular frequency (determines temporal oscillation speed).
    • +
    • t: Time.
    • +
    • φi: Phase offset.
    • +
    • ⋅: Dot product.
    • +
    +

    Simpler forms might fix directions or use scalar frequencies if directionality is not complex.

    +
    +
    + Waves Noise Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Gaussian Noise Mathematics

    +

    Gaussian noise is a statistical noise characterized by a probability density function (PDF) that follows the Gaussian (or normal) distribution. Its values are typically clustered around a mean (μ), with a spread determined by the standard deviation (σ). In image processing, it's often used to simulate random sensor noise or as a basis for other effects.

    +
    + PDF: f(x | μ, σ2) = (1 / (σ√(2π))) × e-((x-μ)2 / (2σ2)) +
    +

    For generating Gaussian noise, random numbers are drawn from this distribution. In practice, libraries provide functions to directly generate such noise (e.g., torch.randn which samples from N(0,1)).

    +
      +
    • μ (Mean): The average value, often 0 for noise.
    • +
    • σ (Standard Deviation): Controls the "spread" or intensity of the noise. σ2 is the variance.
    • +
    +

    Gaussian noise is "white" if its values are statistically independent and identically distributed at each point.

    +
    +
    + Gaussian Noise Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Heterogeneous FBM Mathematics

    +

    Heterogeneous Fractal Brownian Motion (Hetero FBM) is an extension of standard FBM. While standard FBM uses constant parameters (like persistence/amplitude falloff and lacunarity/frequency gain) across all octaves and spatial locations, Hetero FBM allows these parameters, or the base noise characteristics, to vary spatially. This creates textures with non-uniform complexity, where some areas might be smoother and others rougher or more detailed.

    +
    + HeteroFBM(P) = ∑N-1i=0 A(P, i) × Noise(F(P, i) × P, params(P,i)) +
    +

    Where:

    +
      +
    • P: The input coordinate.
    • +
    • N: The number of octaves.
    • +
    • A(P, i): Spatially varying amplitude for octave i at point P.
    • +
    • F(P, i): Spatially varying frequency for octave i at point P.
    • +
    • Noise(..., params(P,i)): Base noise function whose own internal parameters might also vary spatially.
    • +
    +

    The spatial variation itself is often controlled by another noise function or a predefined map. For example, the Hurst exponent (H), which relates to persistence, could be made to vary across the domain.

    +
    +
    + Heterogeneous FBM Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Interference Noise Mathematics

    +

    Interference patterns arise from the superposition of two or more waves. When waves meet, they can reinforce each other (constructive interference) or cancel each other out (destructive interference), depending on their relative phases and amplitudes. This principle is fundamental in physics (e.g., light and sound waves) and can be used to generate complex visual patterns.

    +
    + Pattern(P) = f(Wave1(P), Wave2(P), ..., WaveN(P)) +
    +

    A common way to generate interference is by summing or multiplying wave functions:

    +

    Example with two sine waves:

    +
    + V(P) = A1sin(k1⋅P + φ1) + A2sin(k2⋅P + φ2) +
    +

    Or using noise functions as sources:

    +
    + V(P) = Noise1(P) + Noise2(P × S + O) +
    +

    Where S is a scale and O is an offset for Noise2 to make it different from Noise1. The visual character depends heavily on the frequencies and relative phases of the interfering sources.

    +
    +
    + Interference Noise Implementation +
    + + +
    +
    + +
    +
    + +
    +

    Spectral Noise Mathematics

    +

    Spectral noise generation involves directly defining or manipulating the noise's properties in the frequency domain (its spectrum) using techniques like the Fourier Transform. By controlling the amplitude and phase of different frequencies, a wide variety_of_textures can be created, from smooth to rough, or with specific directional biases.

    +

    The general process is:

    +
      +
    1. Start with white noise in the spatial domain or directly create a spectrum.
    2. +
    3. Compute its Fast Fourier Transform (FFT) to get the frequency domain representation (spectrum).
    4. +
    5. Modify the spectrum: Apply a filter (e.g., 1/fβ for pink/brown/blue noise, band-pass, directional filters).
    6. +
    7. Compute the Inverse Fast Fourier Transform (IFFT) to convert the modified spectrum back to the spatial domain.
    8. +
    +
    + SpatialNoise = IFFT( Filter(Spectrum) × FFT(InitialNoise) ) +
    +

    The filter often takes the form 1/fβ, where f is frequency and β controls the "color" of the noise (e.g., β=0 for white, β=1 for pink, β=2 for brown/red).

    +
    +
    + Spectral Noise Implementation +
    + + +
    +
    + +
    +
    + +
    +

    3D Projection Noise Mathematics

    +

    3D Projection noise involves generating a 3D noise field (e.g., 3D Perlin, Simplex, or FBM) and then sampling a 2D slice from it. This technique creates 2D patterns that appear to have depth, volume, or temporal evolution if the slice position changes over time.

    +
    + Noise2D(x, y) = Noise3D(x × Sxy, y × Sxy, zslice × Sz + T × Vt) +
    +

    Where:

    +
      +
    • (x, y): 2D coordinates for the output noise.
    • +
    • Noise3D: A 3D coherent noise function.
    • +
    • Sxy: Spatial scaling factor for x and y axes.
    • +
    • zslice: The depth or position of the 2D slice along the third dimension (often z).
    • +
    • Sz: Scaling factor for the z-dimension, controlling "thickness" or detail along z.
    • +
    • T: Time parameter for animation.
    • +
    • Vt: Velocity of slicing through the 3D noise field over time.
    • +
    +

    By animating zslice or rotating the sampling plane, dynamic evolving textures can be created from a static 3D noise volume.

    +
    +
    + 3D Projection Noise Implementation +
    + + +
    +
    + +
    +
    +
    + +
    + +
    +

    🎭 Blend Modes ⚖️ The Alchemical Combinations

    +

    ShaderNoiseKSampler offers various blend modes to combine shader noise with base noise, each creating distinct alchemical transformations:

    + +
    +

    🧩 Blend Operations

    +
    +
    +
    Normal
    +

    Simple linear interpolation between base and shader noise, controlled by shader_strength

    +
    result = base * (1.0 - α) + shader * α
    +
    +
    +
    Add
    +

    Adds shader noise to base noise, creating brightened areas where patterns align

    +
    result = base + shader * α
    +
    +
    +
    Multiply
    +

    Multiplies base and shader noise, darkening the overall pattern

    +
    result = base * (shader * α + (1.0 - α))
    +
    +
    +
    Screen
    +

    Inverts, multiplies, then inverts again, brightening patterns

    +
    result = 1.0 - (1.0 - base) * (1.0 - shader * α)
    +
    +
    +
    Overlay
    +

    Combines Multiply and Screen modes for enhanced contrast

    +
    result = base < 0.5 ? 2.0 * base * shader : 1.0 - 2.0 * (1.0 - base) * (1.0 - shader)
    +
    +
    +
    Soft Light
    +

    Darkens or lightens colors depending on shader noise

    +
    result = (1.0 - base) * base * shader + base * (1.0 - (1.0 - base) * (1.0 - shader))
    +
    +
    +
    Hard Light
    +

    More intense version of Overlay with sharper contrast

    +
    result = shader > 0.5 ? 1.0 - (1.0 - base) * (1.0 - 2.0 * (shader - 0.5)) : 2.0 * base * shader
    +
    +
    +
    Difference
    +

    Subtracts darker color from lighter color, creating distinctive edges

    +
    result = base * (1.0 - α) + abs(base - shader) * α
    +
    +
    +
    + +
    +

    🧿 Blend Mode Harmonics

    +

    The choice of blend mode significantly impacts the generated image. Different blend modes resonate with different shader noise types and prompt themes:

    +
      +
    • Multiply + Cellular Noise ⟶ Organic, tissue-like structures
    • +
    • Add + Curl Noise ⟶ Ethereal, flowing energy patterns
    • +
    • Overlay + Tensor Field ⟶ Crystalline, structured patterns
    • +
    • Soft Light + Domain Warp ⟶ Dream-like, fluid transformations
    • +
    • Difference + Spectral Noise ⟶ Boundary-focused, edge-highlighting patterns
    • +
    +

    Experiment with these combinations to find the blend harmonics that resonate with your artistic vision.

    +
    +
    + +
    + +
    +

    🌀 Noise Transformations 💎 Mathematical Metamorphosis

    +

    Beyond the core shader noise patterns, ShaderNoiseKSampler offers additional mathematical transformations that can dramatically alter the character of the generated shader noise:

    + +
    +

    🧮 Mathematical Operators

    +
    +
    +
    Reverse
    +

    Inverts the sign of the noise, turning peaks into valleys and vice versa

    +
    T(noise) = -noise
    +
    +
    +
    Inverse
    +

    Reverses the values within the 0-1 range, preserving the overall pattern

    +
    T(noise) = 1.0 - noise
    +
    +
    +
    Absolute
    +

    Takes the absolute value, creating sharp ridges at zero-crossings

    +
    T(noise) = |noise|
    +
    +
    +
    Square
    +

    Squares the values, enhancing high values and diminishing low values

    +
    T(noise) = noise2
    +
    +
    +
    Square Root
    +

    Takes the square root, enhancing low values and compressing high values

    +
    T(noise) = √noise
    +
    +
    +
    Log
    +

    Takes the logarithm, greatly enhancing low values

    +
    T(noise) = log(noise + ε)
    +
    +
    +
    Sin
    +

    Applies the sine function, creating oscillating patterns

    +
    T(noise) = sin(noise × π)
    +
    +
    +
    Cos
    +

    Applies the cosine function, creating alternating bands

    +
    T(noise) = cos(noise × π)
    +
    +
    +
    + +
    +

    🧪 Transformation Alchemy

    +

    These transformations can dramatically alter the character of shader noise patterns, creating new structural properties:

    +
      +
    • Absolute transform creates ridge-like features along zero-crossings, perfect for geological formations
    • +
    • Square transform enhances high-intensity regions while subduing low-intensity areas, ideal for creating focused areas of interest
    • +
    • Sin/Cos transforms create banded patterns, excellent for stratified or layered structures
    • +
    • Log transform magnifies subtle details in low-intensity regions, revealing hidden structures
    • +
    +

    These transformations can be combined with different shader noise types and blend modes to create an almost infinite variety of structural guidance patterns.

    +
    +
    + +
    + +
    +

    🎬 Temporal Coherence ⏳ Evolving Noise for Animations

    +

    ShaderNoiseKSampler's temporal coherence feature ensures consistent noise patterns. For sequences like video frames, it helps maintain frame-to-frame consistency by evolving noise naturally over time. For single image generations, it ensures the base noise is derived consistently from the main seed, leading to predictable noise structures when parameters are tweaked.

    + +
    +

    ⚙️ Enabling Evolving Animations

    +

    Temporal coherence fundamentally changes how shader noise is generated:

    +
      +
    • use_temporal_coherence (Boolean): This is the primary toggle. When enabled, ShaderNoiseKSampler ensures noise is generated consistently. For videos, it uses techniques (like the TemporalCoherentNoiseGenerator) that treat time as an evolving dimension. For images, it ensures the same base noise is used if the main seed is unchanged.
    • +
    • Consistent Base Seed: For temporal coherence to be effective (for both images and videos), the main seed input must remain constant. For videos, variation then comes from the noise evolving over an internal 'time' parameter. For images, this ensures that if other shader parameters are changed, the underlying base noise structure remains the same.
    • +
    • Consistent/Evolving Patterns: For videos, this mode generates patterns that transform coherently from one frame to the next, leading to more stable animations. For single images, it means the generated noise pattern will be the same for a given seed, even if other parameters (like shader strength or blend mode) are changed, allowing for more predictable exploration.
    • +
    +
    +

    Conceptual animation preview placeholder.

    +
    +
    + +
    +
    + Conceptual Temporal Noise (Python) +
    + + +
    +
    + +
    +
    + +
    + +
    +

    🎭 Shape Masks 💠 Geometric Modulation

    +

    Shape masks provide spatial control by modulating shader noise patterns with procedurally generated geometric overlays. These masks, typically grayscale tensors (0.0 for no effect, 1.0 for full effect), guide the generative process, influencing compositions and textural details.

    + +
    +

    Mask Generation & Application

    +

    Shape masks are procedurally generated within the node as fixed grayscale patterns where values range from 0.0 (no effect area) to 1.0 (full effect area). The generated mask \( M \) is then used to modulate an input field (e.g., a noise or velocity field \( V \)) based on the shape_mask_strength \( \\alpha_s \). The application is a linear interpolation:

    +
    + V_{masked} = V \\times (1 - \\alpha_s) + (V \\times M) \\times \\alpha_s +
    +

    This means if \( \\alpha_s = 1 \), the field is fully multiplied by the mask pattern (\( V \\times M \)). If \( \\alpha_s = 0 \), the mask has no effect. Intermediate values blend between the original and the mask-modulated field. Key characteristics include:

    +
      +
    • Mask Type: Defines the base geometry from a selection including Radial, Linear, and various Geometric or Procedural patterns.
    • +
    • Mask Strength: The \( \\alpha_s \) parameter, controlling the blend intensity of the mask\'s effect.
    • +
    • Edge Handling: Mask types inherently produce different edge styles, some with soft, feathered falloffs (often using smoothstep internally) and others with hard, thresholded edges.
    • +
    +

    Note that while masks might appear animated in visualization previews, in the backend implementation they are static patterns applied at generation time. Any animation effects in the final output would come from changing parameters between frames.

    + + + +
    +
    +
    +
    Radial
    +
    +
    +
    +
    Linear
    +
    +
    +
    +
    Grid
    +
    +
    +
    +
    Vignette
    +
    +
    +
    +
    Spiral
    +
    +
    +
    +
    Hexgrid
    +
    +
    +
    +
    Wavy
    +
    +
    +
    +
    Concentric
    +
    +
    +
    + +
    +
    + Illustrative Shape Mask Application (Python) +
    + + +
    +
    + +
    + +
    +

    🎨 Creative Applications

    +

    The interplay between different mask types, the noise they modulate, and various parameter settings (like shape_mask_strength, noise_scale, etc.) allows for a wide range of artistic effects. Consider these combinations:

    +
      +
    • Vignettes & Focus: Soft Radial or Vignette masks to draw attention centrally.
    • +
    • Structured Organics: Grid Masks combined with Cellular Noise for bio-mechanical effects.
    • +
    • Atmospheric Depth: Linear Gradient Masks to simulate fog or distance with flowing noise.
    • +
    • Selective Detailing: Geometric Masks to apply high-frequency noise to specific regions.
    • +
    +

    Experiment with mask types and their strengths in conjunction with noise parameters to guide the generative process.

    +
    +
    + +
    + +
    +

    🌈 Color Schemes ⚙️ Chromatic Adjustments

    +

    ShaderNoiseKSampler allows for the application of color transformations directly to the generated shader noise patterns. It's important to understand that this is not a traditional post-processing color adjustment. Instead, the color information is integrated into the shader noise before it influences the diffusion model during the sampling process. Consequently, these 'colors' can function as an integral part of the shader noise's texture and structure, subtly guiding the model's interpretation and shaping the features, patterns, and overall aesthetic of the final image. This technique offers a unique way to enhance visual impact and steer the generative outcome.

    + +
    +

    Color Transformation Process

    +

    The color mapping function transforms a normalized noise value t ∈ [0,1] into RGB color space through gradient functions. Each color scheme defines specific transfer functions for each channel, creating distinctive visual characteristics that can alter how the diffusion model interprets the noise structure.

    + +
    + C(t) = (R(t), G(t), B(t)) +
    + +

    Key characteristics of the coloring process include:

    +
      +
    • Color Scheme Selection: Different schemes provide various perceptual encodings of the noise data.
    • +
    • Color Intensity: Controls how strongly the coloring affects the final noise blend, typically between 0.0 (no effect) and 1.0 (full effect).
    • +
    • Channel Blending: The colored information is blended into the noise channels before they influence the diffusion process.
    • +
    + + + +
    +
    + The choice of color scheme significantly impacts how shader noise patterns guide the generative process. Different schemes create various perceptual encodings that can emphasize different aspects of the shader noise structure, potentially influencing the final image's mood, texture, and compositional elements. +
    + +
    Spectral Schemes - Emphasizing Transitions
    +
    +
    + Inferno +
    +
    + Magma +
    +
    + Plasma +
    +
    + Viridis +
    +
    + +
    Technical Schemes - Enhancing Perception
    +
    +
    + Turbo +
    +
    + Jet +
    +
    + Parula +
    +
    + Rainbow +
    +
    + Hot +
    +
    +
    +
    + +
    +

    Applying Color Schemes

    +

    ShaderNoiseKSampler allows for the selection of various color schemes that are applied to the generated noise. The intensity of this coloration can also be controlled, determining how strongly the chosen color scheme influences the noise that guides the diffusion model.

    + +
    +

    Color Scheme Effects on Generation

    +
      +
    • Contrast Enhancement: Color schemes like Inferno and Jet can increase the perceived contrast in the noise pattern, potentially leading to more defined boundaries in the generated image.
    • +
    • Perceptual Organization: Schemes like Viridis and Turbo help to organize the noise data perceptually, which can influence how the model interprets spatial relationships.
    • +
    • Mood and Tone: The predominant hues of a color scheme can subtly influence the mood or tonal quality of the generated image, even when the colors themselves aren't directly visible.
    • +
    • Detail Emphasis: Some schemes are better at emphasizing small details within the noise pattern, which can lead to enhanced textural complexity in the final output.
    • +
    +
    + +
    +
    + Color Scheme Implementation +
    + + +
    +
    + +
    +
    +
    + +
    + +
    +

    🛠️ Usage 🔧 Working with this Tool

    +

    ShaderNoiseKSampler integrates advanced noise generation directly into the ComfyUI sampling process. It replaces the standard KSampler's noise generation with a sophisticated system that allows for multi-stage shader application, diverse noise types, transformations, and blending, offering fine-grained control over the creative output.

    + +
    +

    Core Workflow Integration

    +
      +
    1. Connect Inputs: +
        +
      • model: The primary AI model for generation.
      • +
      • positive & negative: Conditioning prompts.
      • +
      • latent_image: The input latent to be processed.
      • +
      +
    2. +
    3. Basic Sampling Parameters: +
        +
      • seed: For reproducibility use a fixed seed value for consistent results.
      • +
      • steps: Number of sampling iterations.
      • +
      • cfg: Classifier-Free Guidance scale.
      • +
      • sampler_name & scheduler: Standard KSampler choices.
      • +
      • denoise: Controls the extent of denoising.
      • +
      +
    4. +
    5. Shader Noise Configuration: This is where ShaderNoiseKSampler shines: +
        +
      • sequential_stages: Number of shader stages applied sequentially over defined step ranges.
      • +
      • injection_stages: Number of shader stages applied at specific injection points during sampling.
      • +
      • shader_strength: Global strength of the shader noise influence. Set to 0.0 to use only base noise.
      • +
      • blend_mode: How shader noise combines with base noise (e.g., multiply, add, overlay).
      • +
      • noise_transform: Mathematical operations applied to the generated noise (e.g., absolute, sin, sqrt).
      • +
      • use_temporal_coherence: For generating frame-consistent noise, useful in animations.
      • +
      +
    6. +
    7. (Optional) Advanced Control: +
        +
      • custom_sigmas: Provide a custom sigma schedule to override the model's default.
      • +
      +
    8. +
    9. Run Workflow: Execute the ComfyUI graph. The ShaderNoiseKSampler will dynamically generate and blend noise according to your settings throughout the sampling process.
    10. +
    +
    + +
    +

    ⚙️ Key Parameter Explanations

    +
    +
    +
    Sequential vs. Injection Stages
    +

    sequential_stages apply shader noise over segments of the diffusion process. The total steps are divided among these stages. + injection_stages apply shader noise at specific, discrete steps. + Sequential stages don't seem to add time to the generation but injection stages will add some time to the generation. +

    +
    +
    Shader Strength & Distributions
    +

    shader_strength is a global multiplier. Higher shader strength can lead to increased artifacting or generational oddities, but not always and not with all models or shader noise types. Small variations in shader strength is a good way to maintain the sum concepts of an image and not heavily vary its degree of difference.

    +
    +
    +
    Noise Transformation
    +

    The noise_transform (e.g., absolute, sin, sqrt) is a global setting applied to the generated shader noise within each stage before it's blended with the base noise. The chosen transformation is used consistently across all stages.

    +
    +
    +
    Temporal Coherence
    +

    When use_temporal_coherence is enabled, the node aims to generate consistent noise. For videos, this means noise evolves smoothly over a "time" dimension (often using 3D/4D noise with one dimension as time), with the base seed consistent across frames. For images, it ensures the noise pattern is consistently derived from the main seed, even with other parameter changes.

    +
    +
    +
    Custom Sigmas
    +

    custom_sigmas allows advanced users to define their own noise schedule (sigmas), giving more control over how noise is added and removed at each step. This can significantly alter the sampling dynamics.

    +
    +
    +
    Blend Mode
    +

    The blend_mode (e.g., multiply, add, overlay) is a global setting that determines how the shader noise combines with the base noise. This selected blend mode is used for all stages.

    +
    +
    +
    + +
    +

    🧠 Creative Techniques & Advanced Usage

    +
      +
    • Shader Visualizer as a Guide: Using the shader visualizer in connection to the generation process is crucial for gaining insights into your parameter adjustments. The shader display was designed as a visual aid, not just for presentation. It helps you understand the shader noise\'s impact on the output image and serves as a navigational tool for latent space, mirroring backend shader parameters.
    • +
    • Detail through Noise Scale & Octaves: Higher noise scale and octaves can contribute to more detailed images. Essentially, a more "noisy" input increases the likelihood of observing finer-grained detail in the output.
    • +
    • Parameter Interdependence: Parameters are not always independent. If phase shift adjustments seem insufficient, try incorporating a bit of warp strength. Conversely, if your noise scale and octaves are already high, parameters like warp strength and phase shift might appear less impactful.
    • +
    • Color Intensity with Shape Masks: Color intensity is only effective when a shape mask is active. Once active, it can be used to introduce subtle to significant variations in your image, depending on the input values.
    • +
    • Shape Mask Strength: Similar to color intensity, shape mask strength is only usable when a shape mask is active. When enabled, it allows for small to large image variations based on your input values.
    • +
    • Warp & Phase with Active Masks/Colors: You might notice that when a color scheme or a shape mask (especially with higher strength/intensity) is active, the perceptual impact of warp_strength and phase_shift can seem diminished. This is likely because the structural influence of the mask or the channel modifications from coloring are significantly shaping the noise, potentially overshadowing the more subtle distortions or shifts from warp and phase. Experiment with moderating mask/color influence if you need to bring out the warp/phase effects more clearly.
    • +
    • Quick Ideation: For rapid idea generation without the need to constantly save parameters, consider using the (direct) version of this node. As is rapid parameter changes cannot be made to queue in succession, due to the way the node is currently implemented.
    • +
    • Saving Parameters: The "Save Shader Parameters" button (found on the ShaderNoiseKSampler node) allows you to save your current shader settings. These are saved to custom_nodes/ComfyUI-ShaderNoiseKsampler/data/shader_params.json. Note: this file must be named exactly shader_params.json in this specific directory and will be overwritten on each save. This is crucial for features that load these parameters. Use the (direct) version of this node to avoid saving parameters.
    • +
    • Shader Noise Palettes & Performance: Each Shader Noise Type (or palette) offers a distinct lens through which to explore the latent space, revealing different facets and possibilities within a seed. Most shader noise types process at roughly comparable speeds, typically adding only about 3-4 seconds to the generation time compared to a standard KSampler. While some may occasionally take a moment longer to initialize or load, the overall impact on generation time is generally minimal.
    • +
    +
    +
    + +
    + + + +
    + +
    +

    🌅 Conclusion ☯ The Path of Creation

    +

    ShaderNoiseKSampler is a bridge between the mathematical sublime and practical artistry. By understanding and harnessing the sacred geometry of shader noise, artists can guide the generative process with precision and intentionality, transforming pure mathematical concepts into tools of creative expression.

    +

    It is important to note that this project represents a concept in its infancy—a minimal viable product of ongoing research. Much remains to be explored and understood in this fascinating intersection of mathematical noise patterns and generative art. The techniques and approaches presented here are foundational stepping stones in a journey that has only just begun.

    +

    This work constitutes original, independent investigation and implementation—a product of genuine curiosity and hands-on experimentation rather than an adaptation of existing research. The path forward relies on community support, both in collaborative exploration and financial backing, to fuel further innovation. By supporting grassroots research like this, you contribute directly to the advancement of creative technology driven by unfiltered exploration and authentic discovery.

    +

    May your journey with ShaderNoiseKSampler be one of discovery, inspiration, and creation—of digital tapestries woven from the very fabric of mathematical reality.

    +
    + "In every shader noise pattern lies a universe of possibility
    + In every parameter, a doorway to creation"
    + - The Shader Matrix +
    +
    +
    + + + + + + + + `; // 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') as HTMLElement | null; + if (closeButtonInTreatise) { + closeButtonInTreatise.onclick = (e: MouseEvent): void => { + e.stopPropagation(); + 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: KeyboardEvent): void => { + if (e.key === 'Tab') { + const focusableElements = Array.from(modal.querySelectorAll(focusableSelectors)) + .filter((el): el is HTMLElement => (el as HTMLElement).offsetParent !== null && !el.hasAttribute('disabled')); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } + } else { + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + } + } + }); + + // 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(() => { + const closeBtn = content.querySelector('.close-button') as HTMLElement | null; + if (closeBtn) { + closeBtn.focus(); + } + }, 50); + + // Call the renderer for noise visualizations + if (window.NoiseVisualizer?.renderAllInModal) { + setTimeout(() => { + window.NoiseVisualizer!.renderAllInModal(content); + }, 0); + } 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) { + button.options = {}; + } + button.options.section = "advanced"; + + // Custom button style for the KSampler node + button.label = "📊 Show Shader Matrix"; + } 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 { + // For other nodes, add after a small delay to ensure all widgets are ready + setTimeout(addMatrixButton, 50); + } + + const triggerMatrixButton = (): void => { + const matrixButtonWidget = self.widgets.find((w: IWidget) => w.name === "📊 Show Shader Matrix" && w.type === "button"); + if (matrixButtonWidget && typeof matrixButtonWidget.callback === 'function') { + (matrixButtonWidget.callback as Function).call(matrixButtonWidget.value, (app as any).canvas, self, null, null); + } else { + console.warn("Matrix button widget not found or callback is not a function for Alt+M."); + } + }; + + const handleMatrixKeyDown = (event: KeyboardEvent): void => { + if (event.altKey && event.key.toLowerCase() === 'm') { + const appCanvas = (app as any).canvas; + if (appCanvas && (appCanvas.current_node === self || (appCanvas.selected_nodes && appCanvas.selected_nodes[self.id]))) { + const activeEl = document.activeElement as HTMLElement | null; + 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(); + event.stopPropagation(); + triggerMatrixButton(); + } + } + }; + + document.addEventListener('keydown', handleMatrixKeyDown); + self.handleMatrixButtonKeyDown = handleMatrixKeyDown; // Store for removal + + const originalOnRemoved = self.onRemoved; + self.onRemoved = function () { + if (self.handleMatrixButtonKeyDown) { + document.removeEventListener('keydown', self.handleMatrixButtonKeyDown); + delete self.handleMatrixButtonKeyDown; + console.log("Removed Alt+M keydown listener for matrix button on node:", self.id); + } + if (originalOnRemoved) { + originalOnRemoved.apply(self, arguments); + } + }; + // --- End Keybinding Logic --- + } + } + }); +})(); \ No newline at end of file diff --git a/web/src/noise_visualizer.ts b/web/src/noise_visualizer.ts new file mode 100644 index 0000000..bf2db1a --- /dev/null +++ b/web/src/noise_visualizer.ts @@ -0,0 +1,1180 @@ +/** + * noise_visualizer.ts - Noise pattern visualization for ComfyUI modal + */ + +// Make this file a module to allow global augmentation +export { }; + +// ============================ +// Type Definitions +// ============================ + +interface Point { + x: number; + y: number; +} + +interface WaveSource extends Point { + phase?: number; +} + +interface Particle extends Point { + vx: number; + vy: number; + life: number; + initialLife: number; + radius: number; + trail: Point[]; + isPrimordial: boolean; + primordialTime: number; +} + +interface BackgroundPattern { + type: PatternType | null; + variantSeed: number; + alpha: number; + startTime: number; +} + +type PatternType = 'tensor_field' | 'cellular' | 'domain_warp' | 'perlin' | 'curl_noise' | 'waves_interference'; + +interface NoiseVisualizerInterface { + kofiCupImageBitmap: HTMLImageElement | ImageBitmap | null; + kofiImageLoaded: boolean; + kofiImageLoadAttempted: boolean; + _preloadKofiImage: () => Promise; + renderAllInModal: (modalContentElement: HTMLElement) => Promise; + _clearCanvas: (canvas: HTMLCanvasElement, backgroundColor?: string) => CanvasRenderingContext2D; + _drawKofiIcon: (ctx: CanvasRenderingContext2D) => void; + _drawManualKofiCup: (ctx: CanvasRenderingContext2D, x: number, y: number, iconSize: number) => void; + renderPlaceholder: (canvas: HTMLCanvasElement, noiseName: string) => void; + // Noise visualizations + renderTensorField: (canvas: HTMLCanvasElement) => void; + renderCellular: (canvas: HTMLCanvasElement) => void; + renderDomainWarp: (canvas: HTMLCanvasElement) => void; + renderFractal: (canvas: HTMLCanvasElement) => void; + renderPerlin: (canvas: HTMLCanvasElement) => void; + renderWaves: (canvas: HTMLCanvasElement) => void; + renderGaussian: (canvas: HTMLCanvasElement) => void; + renderHeterogeneousFBM: (canvas: HTMLCanvasElement) => void; + renderInterference: (canvas: HTMLCanvasElement) => void; + renderSpectral: (canvas: HTMLCanvasElement) => void; + renderProjection3D: (canvas: HTMLCanvasElement) => void; + renderCurlNoise: (canvas: HTMLCanvasElement) => void; + // Mask visualizations + renderMaskPlaceholder: (canvas: HTMLCanvasElement, maskName: string) => void; + renderMaskRadial: (canvas: HTMLCanvasElement) => void; + renderMaskLinear: (canvas: HTMLCanvasElement) => void; + renderMaskGrid: (canvas: HTMLCanvasElement) => void; + renderMaskVignette: (canvas: HTMLCanvasElement) => void; + renderMaskSpiral: (canvas: HTMLCanvasElement) => void; + renderMaskHexgrid: (canvas: HTMLCanvasElement) => void; + renderMaskWavy: (canvas: HTMLCanvasElement) => void; + renderMaskConcentricRings: (canvas: HTMLCanvasElement) => void; + // Color scheme renders + renderColorInferno: (swatchDiv: HTMLElement) => void; + renderColorMagma: (swatchDiv: HTMLElement) => void; + renderColorPlasma: (swatchDiv: HTMLElement) => void; + renderColorViridis: (swatchDiv: HTMLElement) => void; + renderColorTurbo: (swatchDiv: HTMLElement) => void; + renderColorJet: (swatchDiv: HTMLElement) => void; + renderColorParula: (swatchDiv: HTMLElement) => void; + renderColorRainbow: (swatchDiv: HTMLElement) => void; + renderColorHot: (swatchDiv: HTMLElement) => void; + renderColorBlueRed: (swatchDiv: HTMLElement) => void; + renderColorCool: (swatchDiv: HTMLElement) => void; + renderColorHsv: (swatchDiv: HTMLElement) => void; + renderColorAutumn: (swatchDiv: HTMLElement) => void; + renderColorWinter: (swatchDiv: HTMLElement) => void; + renderColorSpring: (swatchDiv: HTMLElement) => void; + renderColorSummer: (swatchDiv: HTMLElement) => void; + renderColorCopper: (swatchDiv: HTMLElement) => void; + renderColorPink: (swatchDiv: HTMLElement) => void; + renderColorBone: (swatchDiv: HTMLElement) => void; + renderColorOcean: (swatchDiv: HTMLElement) => void; + renderColorTerrain: (swatchDiv: HTMLElement) => void; + renderColorNeon: (swatchDiv: HTMLElement) => void; + renderColorFire: (swatchDiv: HTMLElement) => void; + // Animation renders + renderTemporalAnimation: (canvas: HTMLCanvasElement) => void; + renderIntroNoiseDemo: (canvas: HTMLCanvasElement) => void; + renderColorPlaceholder: (swatchDiv: HTMLElement, schemeName: string) => void; + // Index signature for dynamic method access + [key: string]: unknown; +} + +// Extend Window interface +declare global { + interface Window { + NoiseVisualizer?: NoiseVisualizerInterface; + } +} + +// ============================ +// Implementation +// ============================ + +if (!window.NoiseVisualizer) { + const NoiseVisualizer: NoiseVisualizerInterface = { + kofiCupImageBitmap: null, + kofiImageLoaded: false, + kofiImageLoadAttempted: false, + + _preloadKofiImage: async function (): Promise { + if (this.kofiImageLoadAttempted) return; + this.kofiImageLoadAttempted = true; + + const absolutePath = '/extensions/ComfyUI-ShaderNoiseKsampler/images/kofi_symbol.svg'; + + // Attempt 1: Direct Image load + try { + console.log('Attempting Ko-fi SVG load using new Image() with direct path (Primary Attempt)...'); + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = (): void => { + this.kofiCupImageBitmap = img; + this.kofiImageLoaded = true; + console.log('Ko-fi symbol SVG loaded via Image() successfully (Primary).'); + resolve(); + }; + img.onerror = (e): void => { + console.warn('Primary Ko-fi symbol SVG load via Image() failed. Proceeding to secondary attempt.', e); + reject(e); + }; + img.src = absolutePath; + }); + return; + } catch { + this.kofiImageLoaded = false; + this.kofiCupImageBitmap = null; + } + + // Attempt 2: Fetch -> Blob -> Intermediate Image -> ImageBitmap + let objectURL: string | null = null; + try { + console.log('Attempting Ko-fi SVG load via Blob -> Image -> ImageBitmap (Secondary Attempt)...'); + const response = await fetch(absolutePath); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status} for ${absolutePath}`); + } + const svgText = await response.text(); + const blob = new Blob([svgText], { type: 'image/svg+xml' }); + objectURL = URL.createObjectURL(blob); + + const intermediateImg = new Image(); + await new Promise((resolve, reject) => { + intermediateImg.onload = (): void => resolve(); + intermediateImg.onerror = (): void => reject(new Error('Intermediate Image() load failed for SVG blob (Secondary).')); + intermediateImg.src = objectURL!; + }); + + this.kofiCupImageBitmap = await createImageBitmap(intermediateImg); + this.kofiImageLoaded = true; + console.log('Ko-fi symbol SVG processed to ImageBitmap successfully (Secondary).'); + } catch (error) { + this.kofiImageLoaded = false; + this.kofiCupImageBitmap = null; + console.error('All Ko-fi SVG load attempts failed: ', error, '. Will use fallback drawing.'); + } finally { + if (objectURL) { + URL.revokeObjectURL(objectURL); + } + } + }, + + renderAllInModal: async function (modalContentElement: HTMLElement): Promise { + if (!this.kofiImageLoadAttempted) { + await this._preloadKofiImage(); + } + + const noiseCanvases = modalContentElement.querySelectorAll('.noise-canvas'); + noiseCanvases.forEach(canvasDiv => { + const canvasId = canvasDiv.id; + if (!canvasId || !canvasId.startsWith('noise-canvas-')) return; + const noiseType = canvasId.substring('noise-canvas-'.length); + const noiseName = noiseType.split('_').map(word => { + if (word.toLowerCase() === 'fbm') return 'FBM'; + if (word.toLowerCase() === '3d') return '3D'; + return word.charAt(0).toUpperCase() + word.slice(1); + }).join(' '); + + let canvas = canvasDiv.querySelector('canvas'); + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.width = 130; + canvas.height = 130; + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', `Visualization of ${noiseName} noise pattern`); + canvasDiv.innerHTML = ''; + canvasDiv.appendChild(canvas); + } + + const functionNameSuffix = noiseType.split('_').map(word => { + if (word.toLowerCase() === 'fbm') return 'FBM'; + return word.charAt(0).toUpperCase() + word.slice(1); + }).join(''); + const renderFunctionName = `render${functionNameSuffix.replace('3d', '3D')}`; + + const renderMethod = this[renderFunctionName]; + if (typeof renderMethod === 'function') { + (renderMethod as (canvas: HTMLCanvasElement) => void).call(this, canvas); + } else { + console.warn('No renderer function found:', renderFunctionName, 'for noise type:', noiseType); + this.renderPlaceholder(canvas, noiseName); + } + }); + + // Mask canvases + const maskCanvases = modalContentElement.querySelectorAll('.mask-canvas'); + maskCanvases.forEach(canvasDiv => { + const canvasId = canvasDiv.id; + if (!canvasId || !canvasId.startsWith('mask-canvas-')) return; + const maskType = canvasId.substring('mask-canvas-'.length); + const maskName = maskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); + + let canvas = canvasDiv.querySelector('canvas'); + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 70; + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', `Visualization of ${maskName} shape mask`); + canvasDiv.innerHTML = ''; + canvasDiv.appendChild(canvas); + } + + const functionNameSuffix = maskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(''); + const renderFunctionName = `renderMask${functionNameSuffix}`; + + const renderMethod = this[renderFunctionName]; + if (typeof renderMethod === 'function') { + (renderMethod as (canvas: HTMLCanvasElement) => void).call(this, canvas); + } else { + console.warn('No renderer function found:', renderFunctionName, 'for mask type:', maskType); + this.renderMaskPlaceholder(canvas, maskName); + } + }); + + // Color scheme swatches + const colorSwatches = modalContentElement.querySelectorAll('.color-swatch'); + colorSwatches.forEach(swatchDiv => { + const element = swatchDiv as HTMLElement; + if (element.style.background) return; + + const schemeName = element.textContent?.trim().toLowerCase() ?? ''; + const renderFunctionName = `renderColor${schemeName.charAt(0).toUpperCase() + schemeName.slice(1)}`; + + const renderMethod = this[renderFunctionName]; + if (typeof renderMethod === 'function') { + (renderMethod as (swatchDiv: HTMLElement) => void).call(this, element); + } else { + console.log('Using existing style for color scheme:', schemeName); + } + }); + + // Temporal animation demo + const animationDemoContainer = modalContentElement.querySelector('#animation-demo-placeholder'); + if (animationDemoContainer) { + let canvas = animationDemoContainer.querySelector('canvas'); + if (!canvas) { + canvas = document.createElement('canvas'); + const containerStyle = getComputedStyle(animationDemoContainer); + canvas.width = parseInt(containerStyle.width) || 300; + canvas.height = parseInt(containerStyle.height) || 200; + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', 'Interactive animation demonstrating temporal coherence'); + animationDemoContainer.innerHTML = ''; + animationDemoContainer.appendChild(canvas); + (animationDemoContainer as HTMLElement).style.display = 'block'; + } + if (typeof this.renderTemporalAnimation === 'function') { + this.renderTemporalAnimation(canvas); + } else { + console.warn('renderTemporalAnimation function not found in NoiseVisualizer.'); + this.renderMaskPlaceholder(canvas, 'Animation Demo'); + } + } + + // Intro noise demo + const introNoiseDemoContainer = modalContentElement.querySelector('#intro-noise-demo'); + if (introNoiseDemoContainer) { + let canvas = introNoiseDemoContainer.querySelector('canvas'); + if (!canvas) { + canvas = document.createElement('canvas'); + const containerStyle = getComputedStyle(introNoiseDemoContainer); + canvas.width = parseInt(containerStyle.width) || 300; + canvas.height = parseInt(containerStyle.height) || 350; + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', 'Interactive visualization of noise patterns'); + introNoiseDemoContainer.innerHTML = ''; + introNoiseDemoContainer.appendChild(canvas); + (introNoiseDemoContainer as HTMLElement).style.display = 'block'; + } + if (typeof this.renderIntroNoiseDemo === 'function') { + this.renderIntroNoiseDemo(canvas); + } else { + console.warn('renderIntroNoiseDemo function not found in NoiseVisualizer.'); + this.renderMaskPlaceholder(canvas, 'Intro Noise Demo'); + } + } + }, + + _clearCanvas: function (canvas: HTMLCanvasElement, backgroundColor = '#1a1a2e'): CanvasRenderingContext2D { + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + return ctx; + }, + + _drawKofiIcon: function (ctx: CanvasRenderingContext2D): void { + const iconSize = 18; + const padding = 3; + const x = ctx.canvas.width - iconSize - padding; + const y = ctx.canvas.height - iconSize - padding; + + if (this.kofiImageLoaded && this.kofiCupImageBitmap) { + try { + ctx.drawImage(this.kofiCupImageBitmap, x, y, iconSize, iconSize); + } catch (e) { + console.error('Error drawing local Ko-fi SVG ImageBitmap, falling back to manual draw:', e); + this._drawManualKofiCup(ctx, x, y, iconSize); + } + } else { + this._drawManualKofiCup(ctx, x, y, iconSize); + } + }, + + _drawManualKofiCup: function (ctx: CanvasRenderingContext2D, x: number, y: number, iconSize: number): void { + ctx.save(); + ctx.fillStyle = '#FFDD99'; + ctx.strokeStyle = '#D2B48C'; + ctx.lineWidth = 1; + + ctx.beginPath(); + ctx.moveTo(x, y + iconSize * 0.2); + ctx.lineTo(x, y + iconSize * 0.9); + ctx.quadraticCurveTo(x + iconSize * 0.5, y + iconSize * 1.1, x + iconSize, y + iconSize * 0.9); + ctx.lineTo(x + iconSize, y + iconSize * 0.2); + ctx.quadraticCurveTo(x + iconSize * 0.5, y, x, y + iconSize * 0.2); + ctx.fill(); + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x + iconSize * 0.9, y + iconSize * 0.5, iconSize * 0.25, -Math.PI / 2, Math.PI / 2); + ctx.stroke(); + + ctx.strokeStyle = '#FFFFFF'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(x + iconSize * 0.3, y + iconSize * 0.1); + ctx.quadraticCurveTo(x + iconSize * 0.2, y - iconSize * 0.2, x + iconSize * 0.4, y - iconSize * 0.3); + ctx.moveTo(x + iconSize * 0.6, y + iconSize * 0.05); + ctx.quadraticCurveTo(x + iconSize * 0.5, y - iconSize * 0.3, x + iconSize * 0.7, y - iconSize * 0.4); + ctx.stroke(); + + ctx.restore(); + }, + + renderPlaceholder: function (canvas: HTMLCanvasElement, noiseName: string): void { + const ctx = this._clearCanvas(canvas, '#2c2c34'); + const nameToShow = noiseName.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); + + ctx.fillStyle = '#e0e0e8'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = 'bold 12px "Segoe UI", Tahoma, Geneva, Verdana, sans-serif'; + + const words = nameToShow.split(' '); + if (words.length > 2) { + ctx.fillText(words.slice(0, 2).join(' '), canvas.width / 2, canvas.height / 2 - 7); + ctx.fillText(words.slice(2).join(' '), canvas.width / 2, canvas.height / 2 + 7); + } else { + ctx.fillText(nameToShow, canvas.width / 2, canvas.height / 2); + } + ctx.strokeStyle = '#555'; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + // --- Visualization Functions --- + renderTensorField: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + ctx.strokeStyle = 'rgba(138, 43, 226, 0.7)'; + ctx.lineWidth = 1; + for (let i = 0; i < 10; i++) { + ctx.beginPath(); + const y = (i + 0.5) * (canvas.height / 10); + ctx.moveTo(0, y); + for (let x = 0; x <= canvas.width; x += 5) { + const angle = Math.sin(x * 0.1 + y * 0.05) * Math.PI * 0.25; + ctx.lineTo(x + Math.cos(angle) * 5, y + Math.sin(angle) * 5); + ctx.moveTo(x, y); + } + ctx.stroke(); + } + }, + + renderCellular: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + const numCells = 8; + const points: Point[] = []; + for (let i = 0; i < numCells; i++) { + points.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height }); + } + for (let x = 0; x < canvas.width; x += 4) { + for (let y = 0; y < canvas.height; y += 4) { + let minDist = Infinity; + points.forEach(p => { + minDist = Math.min(minDist, Math.hypot(p.x - x, p.y - y)); + }); + const intensity = Math.min(255, minDist * 2); + ctx.fillStyle = `rgb(${intensity},${intensity},${intensity + 50})`; + ctx.fillRect(x, y, 4, 4); + } + } + this._drawKofiIcon(ctx.canvas.getContext('2d')!); + }, + + renderDomainWarp: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + ctx.lineWidth = 1.5; + for (let i = 0; i < 20; i++) { + ctx.beginPath(); + ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height); + ctx.strokeStyle = `rgba(${100 + Math.random() * 155}, ${100 + Math.random() * 155}, ${200 + Math.random() * 55}, 0.6)`; + for (let j = 0; j < 5; j++) { + const x = Math.random() * canvas.width; + const y = Math.random() * canvas.height; + const cp1x = Math.random() * canvas.width; + const cp1y = Math.random() * canvas.height; + const cp2x = Math.random() * canvas.width; + const cp2y = Math.random() * canvas.height; + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y); + } + ctx.stroke(); + } + }, + + renderFractal: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + const scale = 0.05; + const octaves = 4; + const persistence = 0.5; + const lacunarity = 2.0; + + for (let x = 0; x < canvas.width; x += 2) { + for (let y = 0; y < canvas.height; y += 2) { + let total = 0; + let frequency = 1; + let amplitude = 1; + let maxValue = 0; + + for (let i = 0; i < octaves; i++) { + const noiseVal = (Math.sin(x * scale * frequency + y * scale * frequency * 0.7) + + Math.cos(y * scale * frequency - x * scale * frequency * 0.3)) / 2; + total += noiseVal * amplitude; + maxValue += amplitude; + amplitude *= persistence; + frequency *= lacunarity; + } + + const normalizedTotal = (total / maxValue + 1) / 2; + const colorVal = Math.floor(normalizedTotal * 200) + 55; + ctx.fillStyle = `rgb(${colorVal}, ${colorVal * 0.95}, ${colorVal * 0.9})`; + ctx.fillRect(x, y, 2, 2); + } + } + this._drawKofiIcon(ctx.canvas.getContext('2d')!); + }, + + renderPerlin: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + for (let x = 0; x < canvas.width; x += 3) { + for (let y = 0; y < canvas.height; y += 3) { + const noiseVal = (Math.sin(x * 0.05 + Math.cos(y * 0.08)) + Math.cos(y * 0.06)) / 2; + const intensity = (noiseVal + 1) / 2 * 200 + 55; + ctx.fillStyle = `rgba(${intensity * 0.8}, ${intensity * 0.9}, ${intensity}, 1)`; + ctx.fillRect(x, y, 3, 3); + } + } + this._drawKofiIcon(ctx.canvas.getContext('2d')!); + }, + + renderWaves: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + ctx.strokeStyle = 'rgba(52, 152, 219, 0.7)'; + ctx.lineWidth = 1.5; + for (let i = 0; i < 15; i++) { + ctx.beginPath(); + const startY = i * (canvas.height / 15); + ctx.moveTo(0, startY); + for (let x = 0; x <= canvas.width; x += 5) { + const yOffset = Math.sin(x * 0.1 + i * 0.5) * 10; + ctx.lineTo(x, startY + yOffset); + } + ctx.stroke(); + } + this._drawKofiIcon(ctx.canvas.getContext('2d')!); + }, + + renderGaussian: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + for (let i = 0; i < 10000; i++) { + const x = Math.random() * canvas.width; + const y = Math.random() * canvas.height; + const intensity = Math.floor(Math.random() * 100) + 100; + ctx.fillStyle = `rgba(${intensity * 0.8}, ${intensity * 0.9}, ${intensity}, ${Math.random() * 0.5 + 0.1})`; + ctx.fillRect(x - 1, y - 1, 2, 2); + } + this._drawKofiIcon(ctx.canvas.getContext('2d')!); + }, + + renderHeterogeneousFBM: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + for (let region = 0; region < 3; region++) { + const regionX = (canvas.width / 3) * region; + const regionW = canvas.width / 3; + const step = 2 + region * 2; + for (let x = 0; x < regionW; x += step) { + for (let y = 0; y < canvas.height; y += step) { + const noiseVal = Math.random(); + const intensity = noiseVal * 255; + ctx.fillStyle = `rgb(${intensity}, ${intensity}, ${intensity})`; + ctx.fillRect(regionX + x, y, step, step); + } + } + } + this._drawKofiIcon(ctx.canvas.getContext('2d')!); + }, + + renderInterference: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + ctx.lineWidth = 0.5; + const sources: WaveSource[] = [ + { x: canvas.width * 0.2, y: canvas.height * 0.3, phase: 0 }, + { x: canvas.width * 0.8, y: canvas.height * 0.7, phase: Math.PI / 2 } + ]; + for (let x = 0; x < canvas.width; x += 3) { + for (let y = 0; y < canvas.height; y += 3) { + let sum = 0; + sources.forEach(s => { + const dist = Math.hypot(s.x - x, s.y - y); + sum += Math.sin(dist * 0.1 + (s.phase ?? 0)); + }); + const intensity = (sum / sources.length + 1) / 2 * 255; + ctx.fillStyle = `rgb(${intensity * 0.7}, ${intensity}, ${intensity * 0.8})`; + ctx.fillRect(x, y, 3, 3); + } + } + this._drawKofiIcon(ctx.canvas.getContext('2d')!); + }, + + renderSpectral: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + for (let i = 0; i < canvas.height; i += 4) { + const freqComponent = Math.sin(i * 0.1) * 0.3 + Math.cos(i * 0.05) * 0.3 + Math.random() * 0.4; + const intensity = (freqComponent + 1) / 2 * 255; + ctx.fillStyle = `rgb(${intensity}, ${intensity * 0.8}, ${intensity * 0.6})`; + ctx.fillRect(0, i, canvas.width, 4); + ctx.strokeStyle = 'rgba(0,0,0,0.2)'; + ctx.strokeRect(0, i, canvas.width, 4); + } + this._drawKofiIcon(ctx.canvas.getContext('2d')!); + }, + + renderProjection3D: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + for (let i = 0; i < 50; i++) { + const z = Math.random(); + const x = (Math.random() - 0.5) * canvas.width * (1 + z) + canvas.width / 2; + const y = (Math.random() - 0.5) * canvas.height * (1 + z) + canvas.height / 2; + const size = (1 - z) * 10 + 2; + const opacity = (1 - z) * 0.7 + 0.1; + ctx.fillStyle = `rgba(${150 + z * 105}, ${150 + z * 105}, ${200 + z * 55}, ${opacity})`; + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fill(); + } + this._drawKofiIcon(ctx.canvas.getContext('2d')!); + }, + + renderCurlNoise: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + ctx.lineWidth = 1; + for (let i = 0; i < 50; i++) { + let x = Math.random() * canvas.width; + let y = Math.random() * canvas.height; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.strokeStyle = `rgba(${100 + Math.random() * 100}, ${150 + Math.random() * 105}, ${200 + Math.random() * 55}, 0.5)`; + for (let step = 0; step < 20; step++) { + const angle = Math.sin(x * 0.02 + y * 0.03) * Math.PI + Math.cos(y * 0.02 - x * 0.01) * Math.PI; + x += Math.cos(angle) * 5; + y += Math.sin(angle) * 5; + if (x < 0 || x > canvas.width || y < 0 || y > canvas.height) break; + ctx.lineTo(x, y); + } + ctx.stroke(); + } + }, + + // Mask Placeholder + renderMaskPlaceholder: function (canvas: HTMLCanvasElement, maskName: string): void { + const ctx = this._clearCanvas(canvas, '#2c2c34'); + const nameToShow = maskName.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); + + ctx.fillStyle = '#e0e0e8'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = 'bold 10px "Segoe UI", Tahoma, Geneva, Verdana, sans-serif'; + ctx.fillText(nameToShow, canvas.width / 2, canvas.height / 2); + ctx.strokeStyle = '#555'; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + // Mask Visualization Functions + renderMaskRadial: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const maxRadius = Math.min(canvas.width, canvas.height) / 2; + + const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, maxRadius); + gradient.addColorStop(0, 'rgba(255, 255, 255, 1.0)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0.0)'); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + renderMaskLinear: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + + const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + gradient.addColorStop(0, 'rgba(255, 255, 255, 1.0)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0.0)'); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + renderMaskGrid: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + const cellSize = 20; + + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + + for (let x = 0; x < canvas.width; x += cellSize) { + for (let y = 0; y < canvas.height; y += cellSize) { + if ((Math.floor(x / cellSize) + Math.floor(y / cellSize)) % 2 === 0) { + ctx.fillRect(x, y, cellSize, cellSize); + } + } + } + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + renderMaskVignette: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY); + + const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, maxRadius); + gradient.addColorStop(0, 'rgba(255, 255, 255, 0.0)'); + gradient.addColorStop(0.6, 'rgba(255, 255, 255, 0.3)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 1.0)'); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + renderMaskSpiral: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const maxRadius = Math.min(canvas.width, canvas.height) / 2; + + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.strokeStyle = 'rgba(255, 255, 255, 1.0)'; + ctx.lineWidth = 5; + + ctx.beginPath(); + for (let theta = 0; theta < 8 * Math.PI; theta += 0.1) { + const radius = (maxRadius / (8 * Math.PI)) * theta; + const x = centerX + radius * Math.cos(theta); + const y = centerY + radius * Math.sin(theta); + + if (theta === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + renderMaskHexgrid: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + const hexSize = 15; + const hexHeight = hexSize * Math.sqrt(3); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + + for (let row = 0; row < canvas.height / hexHeight + 1; row++) { + for (let col = 0; col < canvas.width / (hexSize * 3) + 1; col++) { + const offsetX = (row % 2) * hexSize * 1.5; + const x = col * hexSize * 3 + offsetX; + const y = row * hexHeight; + + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const angle = i * Math.PI / 3; + const pX = x + hexSize * Math.cos(angle); + const pY = y + hexSize * Math.sin(angle); + + if (i === 0) { + ctx.moveTo(pX, pY); + } else { + ctx.lineTo(pX, pY); + } + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + } + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + renderMaskWavy: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.strokeStyle = 'rgba(255, 255, 255, 1.0)'; + ctx.lineWidth = 3; + + const amplitude = 10; + const frequency = 0.05; + + for (let y = 20; y < canvas.height; y += 20) { + ctx.beginPath(); + for (let x = 0; x <= canvas.width; x += 2) { + const yOffset = Math.sin(x * frequency) * amplitude; + if (x === 0) { + ctx.moveTo(x, y + yOffset); + } else { + ctx.lineTo(x, y + yOffset); + } + } + ctx.stroke(); + } + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + renderMaskConcentricRings: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas); + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + + ctx.strokeStyle = 'rgba(255, 255, 255, 1.0)'; + ctx.lineWidth = 2; + + const ringCount = 6; + const maxRadius = Math.min(canvas.width, canvas.height) / 2; + const step = maxRadius / ringCount; + + for (let i = 1; i <= ringCount; i++) { + const radius = i * step; + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + ctx.stroke(); + + if (i % 2 === 0) { + ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + ctx.fill(); + } + } + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + }, + + // Color scheme rendering functions + renderColorInferno: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #000004, #3b0f70, #8c2981, #de4968, #fe9f6d, #fcfdbf)'; + }, + + renderColorMagma: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #000004, #3b0f70, #8c2981, #de4968, #fe9f6d, #fcfdbf)'; + }, + + renderColorPlasma: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #0d0887, #7e03a8, #cc4678, #f89441, #f0f921)'; + }, + + renderColorViridis: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #440154, #30678d, #35b778, #fde724)'; + }, + + renderColorTurbo: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #30123b, #4669db, #26bf8c, #d4ff50, #fab74c, #ba0100)'; + }, + + renderColorJet: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #00007f, #0000ff, #00ffff, #ffff00, #ff0000, #7f0000)'; + }, + + renderColorParula: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #352a87, #0f5cdd, #00b5a6, #ffc337, #fcfea4)'; + }, + + renderColorRainbow: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #0000ff, #00ffff, #00ff00, #ffff00, #ff0000)'; + }, + + renderColorHot: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #000000, #ff0000, #ffff00, #ffffff)'; + }, + + renderColorBlueRed: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #0000ff, #7777ff, #ffffff, #ff7777, #ff0000)'; + }, + + renderColorCool: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #00ffff, #77aaff, #aa77ff, #ff00ff)'; + }, + + renderColorHsv: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)'; + }, + + renderColorAutumn: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #ff0000, #ff7700, #ffaa00, #ffdd00, #ffff00)'; + }, + + renderColorWinter: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #0000ff, #0077cc, #00aabb, #00ddaa)'; + }, + + renderColorSpring: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #ff00ff, #ff33cc, #ff77aa, #ffaa77, #ffdd44, #ffff00)'; + }, + + renderColorSummer: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #004433, #008855, #44aa66, #88cc77, #ccee88, #ffff66)'; + }, + + renderColorCopper: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #000000, #331100, #662200, #993300, #cc6644, #ff9966)'; + }, + + renderColorPink: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #0a0a0a, #550055, #aa0066, #ff44aa, #ffaadd, #ffffff)'; + }, + + renderColorBone: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #000000, #2a2a3a, #5a748a, #9ebacb, #dfdfef, #ffffff)'; + }, + + renderColorOcean: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #000000, #000066, #0000bb, #0066cc, #00ccff, #99ffff)'; + }, + + renderColorTerrain: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #333399, #009966, #66cc33, #cccc33, #ff9933, #ffffff)'; + }, + + renderColorNeon: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #ff00ff, #aa00ff, #5500ff, #0000ff, #00aaff, #00ffff, #00ff00, #aaff00, #ffff00)'; + }, + + renderColorFire: function (swatchDiv: HTMLElement): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #000000, #330000, #660000, #bb0000, #ff0000, #ff7700, #ffdd00, #ffffff)'; + }, + + // Temporal animation + renderTemporalAnimation: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas, '#0a0a0a'); + let time = 0; + + function drawPattern(currentTime: number): void { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const numLines = 20; + const maxOffset = 20; + + ctx.strokeStyle = 'rgba(52, 152, 219, 0.6)'; + ctx.lineWidth = 1.5; + + for (let i = 0; i < numLines; i++) { + ctx.beginPath(); + const startY = (i / numLines) * canvas.height; + ctx.moveTo(0, startY); + for (let x = 0; x <= canvas.width; x += 5) { + const yOffset = Math.sin(x * 0.02 + i * 0.3 + currentTime * 0.05) * maxOffset * Math.sin(currentTime * 0.02 + i * 0.1); + const actualY = startY + yOffset; + ctx.lineTo(x, actualY); + } + ctx.stroke(); + } + } + + function animate(): void { + drawPattern(time); + time += 0.1; + if (canvas.isConnected) { + requestAnimationFrame(animate); + } + } + + if (!canvas.dataset.animationRunning) { + canvas.dataset.animationRunning = 'true'; + animate(); + } + }, + + // Intro noise demo - complex particle animation + renderIntroNoiseDemo: function (canvas: HTMLCanvasElement): void { + const ctx = this._clearCanvas(canvas, '#0f0f12'); + let time = 0; + const particles: Particle[] = []; + const numParticles = 120; + const particleColorBase = [138, 43, 226]; + const particleColorHighlight = [224, 224, 232]; + const loopCycleDuration = 420; + let lastLoopInstance = -1; + const currentBackgroundPattern: BackgroundPattern = { type: null, variantSeed: 0, alpha: 0, startTime: 0 }; + const primordialDuration = 60; + + // Initialize particles + for (let i = 0; i < numParticles; i++) { + particles.push({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + vx: 0, + vy: 0, + life: 0, + initialLife: Math.random() * 100 + 90, + radius: Math.random() * 1.7 + 0.6, + trail: [], + isPrimordial: true, + primordialTime: 0 + }); + } + + function noiseField(x: number, y: number, t: number): number { + const baseScale = 0.009; + const timeScale = 0.0023; + const loopTime = t % loopCycleDuration; + const currentLoopInst = Math.floor(t / loopCycleDuration); + + const globalRotationAngle = currentLoopInst * 0.15; + const cosA = Math.cos(globalRotationAngle); + const sinA = Math.sin(globalRotationAngle); + + const rotatedX = x * cosA - y * sinA; + const rotatedY = x * sinA + y * cosA; + + let baseChaosVal = 0; + const scale1 = baseScale * 1.8; + baseChaosVal += Math.sin(rotatedX * scale1 + loopTime * timeScale * 1.1) * + Math.cos(rotatedY * scale1 * 0.8 - loopTime * timeScale * 0.9); + const scale2 = baseScale * 0.9; + baseChaosVal += Math.sin(rotatedY * scale2 * 1.1 - loopTime * timeScale * 1.3) * + Math.cos(rotatedX * scale2 * 0.9 + loopTime * timeScale * 1.0) * 0.8; + const scale3 = baseScale * 1.2; + const innerAngleOffset = Math.sin(loopTime * 0.0008 + currentLoopInst * 0.05) * 0.5; + const N_x_chaos = rotatedX * Math.cos(innerAngleOffset) - rotatedY * Math.sin(innerAngleOffset); + const N_y_chaos = rotatedX * Math.sin(innerAngleOffset) + rotatedY * Math.cos(innerAngleOffset); + baseChaosVal += Math.sin(N_x_chaos * scale3 * 0.6 + loopTime * timeScale * 0.7) * + Math.cos(N_y_chaos * scale3 * 0.7 - loopTime * timeScale * 0.5) * 0.9; + + return (baseChaosVal / 2.7) * Math.PI * 4.0; + } + + function drawIntroPattern(): void { + const currentLoopInst = Math.floor(time / loopCycleDuration); + const isNewLoopStart = (time % loopCycleDuration) < (60 / 2.5); + + if (isNewLoopStart && currentLoopInst !== lastLoopInstance) { + ctx.fillStyle = 'rgba(15, 15, 18, 0.45)'; + lastLoopInstance = currentLoopInst; + + const shaderPatternTypes: PatternType[] = ['tensor_field', 'cellular', 'domain_warp', 'perlin', 'curl_noise', 'waves_interference']; + currentBackgroundPattern.type = shaderPatternTypes[currentLoopInst % shaderPatternTypes.length]; + currentBackgroundPattern.variantSeed = currentLoopInst; + currentBackgroundPattern.alpha = 1.0; + currentBackgroundPattern.startTime = time; + + particles.forEach(p => { + p.x = Math.random() * canvas.width; + p.y = Math.random() * canvas.height; + p.vx = (Math.random() - 0.5) * 0.1; + p.vy = (Math.random() - 0.5) * 0.1; + p.life = p.initialLife; + p.trail = []; + p.isPrimordial = true; + p.primordialTime = 0; + }); + } else { + ctx.fillStyle = 'rgba(15, 15, 18, 0.015)'; + } + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Update and draw particles + particles.forEach(p => { + if (p.isPrimordial) { + p.primordialTime++; + if (p.primordialTime >= primordialDuration) { + p.isPrimordial = false; + } + } + + const finalAngle = noiseField(p.x, p.y, time); + p.vx += Math.cos(finalAngle) * 0.10; + p.vy += Math.sin(finalAngle) * 0.10; + p.vx *= 0.94; + p.vy *= 0.94; + p.x += p.vx; + p.y += p.vy; + + p.trail.push({ x: p.x, y: p.y }); + if (p.trail.length > 25) { + p.trail.shift(); + } + + let trailBaseAlpha = 0.8; + let particleHeadBaseAlpha = 0.65; + let currentParticleRadius = p.radius; + let r = particleColorBase[0], g = particleColorBase[1], b = particleColorBase[2]; + + if (p.isPrimordial) { + const primordialRatio = p.primordialTime / primordialDuration; + trailBaseAlpha *= primordialRatio * 0.5; + particleHeadBaseAlpha = primordialRatio * 0.5; + currentParticleRadius = p.radius * (0.3 + primordialRatio * 0.7); + r = (particleColorBase[0] * primordialRatio) + (70 * (1 - primordialRatio)); + g = (particleColorBase[1] * primordialRatio) + (70 * (1 - primordialRatio)); + b = (particleColorBase[2] * primordialRatio) + (90 * (1 - primordialRatio)); + } + + if (p.trail.length > 1) { + ctx.beginPath(); + ctx.moveTo(p.trail[0].x, p.trail[0].y); + for (let i = 1; i < p.trail.length; i++) { + const trailSegmentAlpha = (i / p.trail.length) * trailBaseAlpha; + ctx.strokeStyle = `rgba(200, 200, 200, ${trailSegmentAlpha})`; + ctx.lineWidth = currentParticleRadius * (i / p.trail.length) * 1.1; + ctx.lineTo(p.trail[i].x, p.trail[i].y); + } + ctx.stroke(); + } + + const lifeRatio = p.life / p.initialLife; + const emergenceFactor = p.isPrimordial ? (p.primordialTime / primordialDuration) : (1.0 - Math.pow(1.0 - lifeRatio, 2)); + const finalRadius = p.isPrimordial ? currentParticleRadius : p.radius * emergenceFactor; + const mainParticleAlpha = emergenceFactor * particleHeadBaseAlpha; + const highlightAlpha = emergenceFactor * (particleHeadBaseAlpha + 0.1); + + ctx.beginPath(); + ctx.arc(p.x, p.y, Math.max(0.1, finalRadius), 0, Math.PI * 2); + if (!p.isPrimordial) { + const speed = Math.hypot(p.vx, p.vy); + r = Math.min(255, particleColorBase[0] + speed * 15); + g = Math.min(255, particleColorBase[1] - speed * 5); + b = Math.min(255, particleColorBase[2] + speed * 5); + } + ctx.fillStyle = `rgba(${Math.floor(r)}, ${Math.floor(g)}, ${Math.floor(b)}, ${mainParticleAlpha})`; + ctx.fill(); + + if (finalRadius > 0.5) { + ctx.beginPath(); + ctx.arc(p.x, p.y, Math.max(0.1, finalRadius * 0.5), 0, Math.PI * 2); + ctx.fillStyle = `rgba(${particleColorHighlight[0]}, ${particleColorHighlight[1]}, ${particleColorHighlight[2]}, ${highlightAlpha})`; + ctx.fill(); + } + + p.life--; + + if (p.life <= 0 || p.x < -finalRadius * 2 || p.x > canvas.width + finalRadius * 2 || p.y < -finalRadius * 2 || p.y > canvas.height + finalRadius * 2) { + p.x = Math.random() * canvas.width; + p.y = Math.random() * canvas.height; + p.vx = (Math.random() - 0.5) * 0.1; + p.vy = (Math.random() - 0.5) * 0.1; + p.life = p.initialLife; + p.trail = []; + p.isPrimordial = true; + p.primordialTime = 0; + } + }); + } + + function animateIntro(): void { + drawIntroPattern(); + time++; + if (canvas.isConnected) { + requestAnimationFrame(animateIntro); + } + } + + if (!canvas.dataset.animationRunningIntro) { + canvas.dataset.animationRunningIntro = 'true'; + animateIntro(); + } + }, + + renderColorPlaceholder: function (swatchDiv: HTMLElement, schemeName: string): void { + swatchDiv.style.background = 'linear-gradient(to bottom, #333333, #666666, #999999, #cccccc)'; + + const nameSpan = document.createElement('span'); + nameSpan.textContent = schemeName; + nameSpan.style.position = 'absolute'; + nameSpan.style.top = '50%'; + nameSpan.style.left = '50%'; + nameSpan.style.transform = 'translate(-50%, -50%)'; + nameSpan.style.color = 'white'; + nameSpan.style.textShadow = '1px 1px 1px black'; + nameSpan.style.fontSize = '10px'; + + swatchDiv.appendChild(nameSpan); + } + }; + + window.NoiseVisualizer = NoiseVisualizer; + + // Use local reference for type safety + if (document.readyState === 'complete' || document.readyState === 'interactive') { + (async (): Promise => { await NoiseVisualizer._preloadKofiImage(); })(); + } else { + document.addEventListener('DOMContentLoaded', async () => { + await NoiseVisualizer._preloadKofiImage(); + }); + } +} diff --git a/web/src/shader_params_save_button.ts b/web/src/shader_params_save_button.ts new file mode 100644 index 0000000..0534c16 --- /dev/null +++ b/web/src/shader_params_save_button.ts @@ -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 { + 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 { + // 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); diff --git a/web/src/shader_renderer.ts b/web/src/shader_renderer.ts new file mode 100644 index 0000000..3a49c8f --- /dev/null +++ b/web/src/shader_renderer.ts @@ -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; + shaderSources: Record; + 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 = { + "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 = { + '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 = { + '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 = { + "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; + }; + } +}); diff --git a/web/src/video_comparer.ts b/web/src/video_comparer.ts new file mode 100644 index 0000000..1927613 --- /dev/null +++ b/web/src/video_comparer.ts @@ -0,0 +1,1178 @@ +/** + * VideoComparer.ts + * Video comparison widget for ComfyUI with multiple display modes + */ + +// @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, ContextMenuItem } from "../types/litegraph"; + +export { }; + +console.log("VideoComparer module loaded"); + +// === TYPE DEFINITIONS === + +interface FrameData { + filename?: string; + subfolder?: string; + type?: string; + frame_index: number; + data_url?: string; +} + +interface VideoData { + name: string; + fps: number; + frames: FrameData[]; + index: number; + is_video_a?: boolean; + is_video_b?: boolean; +} + +interface VideoWidgetValue { + video_data: VideoData[]; + difference_data?: Record; + histogram_data?: unknown[]; +} + +interface ComparerProperties { + comparer_mode: string; + selected_video: string; + onionSkinOpacity: number; + user_resized: boolean; + [key: string]: unknown; +} + +// Custom properties added to HTMLImageElement for tracking load state +type LoadingImage = HTMLImageElement & { + isLoading?: boolean; + queued?: boolean; + failed?: boolean; +}; + +interface LoadQueueItem { + frameData: FrameData; + videoId: string; + cacheKey: string; + retryCount: number; +} + +interface ComparerNode extends LGraphNode { + properties: ComparerProperties; + videoComparerWidget?: VideoComparerWidget; + layoutWidget?: IWidget; + videoSelectorWidget?: IWidget; + onionSkinOpacitySlider?: IWidget; + isPointerOver?: boolean; + pointerOverPos?: [number, number]; + selectedVideo?: string; + updateControlsVisibility?(): void; + setSize?(size: [number, number]): void; +} + +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; + }; +} + +// === CACHE FOR RENDERING OPTIMIZATION === +const CACHE: RenderCache = { + titleCanvas: null, + titleCtx: null, + lastWidth: 0, + lastHeight: 0, + lastTime: 0, + frameCount: 0, + frameSkip: 2, + collapsed: { + canvas: null, + ctx: null, + lastWidth: 0 + } +}; + +// === HELPER FUNCTIONS === + +function imageDataToUrl(data: FrameData): string { + if (!data || !data.filename) { + console.error("[VideoComparer] Invalid image data", data); + return ""; + } + return (api as any).apiURL(`/view?filename=${encodeURIComponent(data.filename)}&type=${encodeURIComponent(data.type || "")}&subfolder=${encodeURIComponent(data.subfolder || "")}${(app as any).getPreviewFormatParam()}${(app as any).getRandParam()}`); +} + +function drawGoldenEyeball(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, size: number, shimmerPosition: number): void { + const eyeWidth = size * 1.6, eyeHeight = size * 1.0; + const irisRadius = size * 0.35, 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 etched 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 shadows + const rayCount = 6, 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 base + 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 < 6; i++) { + const angle = (i / 6) * 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.restore(); +} + +function drawGradientTitle(node: LGraphNode, 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; + 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(); + + drawGoldenEyeball(ctx, width / 2, eyeballY, eyeballSize, shimmerPosition); + ctx.restore(); +} + +// === VIDEO COMPARER WIDGET CLASS === + +class VideoComparerWidget implements IWidget { + name: string; + type: string = "video_comparer"; + node: ComparerNode; + y?: number; + last_y?: number; + + private _value: VideoWidgetValue = { video_data: [] }; + private loadedFramesA: Record = {}; + private loadedFramesB: Record = {}; + private framesA: FrameData[] = []; + private framesB: FrameData[] = []; + private currentFrameIndex: number = 0; + private isPlaying: boolean = false; + private animationFrame: number | null = null; + private fps: number = 8; + + private loadingQueue: LoadQueueItem[] = []; + private activeLoads: Set = new Set(); + private maxConcurrentLoads: number = 3; + private retryAttempts: number = 3; + private loadingInProgress: boolean = false; + + private videosA: VideoData[] = []; + private videosB: VideoData[] = []; + private currentPairIndex: number = 0; + private maxPairs: number = 0; + + private currentBatchPage: number = 0; + private pairsPerPage: number = 2; + private maxBatchPages: number = 0; + + private isInitialLoading: boolean = false; + private loadedFrameCount: number = 0; + private targetLoadCount: number = 0; + private initialLoadTimer: ReturnType | null = null; + private pendingCanvasUpdate: boolean = false; + private lastFrameTime: number = 0; + private _drawCallCount: number = 0; + private canvasUpdateTimer: ReturnType | null = null; + + constructor(name: string, node: ComparerNode) { + console.log("[VideoComparer] Widget constructor called"); + this.name = name; + this.node = node; + } + + set value(v: VideoWidgetValue) { + console.log("[VideoComparer] Widget value setter called"); + this.isPlaying = false; + this.currentFrameIndex = 0; + this.stopPlayback(); + + const videoData = v.video_data || []; + const videosA = videoData.filter(video => video.name === "video_a" || video.is_video_a); + const videosB = videoData.filter(video => video.name === "video_b" || video.is_video_b); + + this.videosA = videosA.map((video, index) => ({ + name: `A${index + 1}`, + fps: video.fps || 8, + frames: video.frames || [], + index + })); + + this.videosB = videosB.map((video, index) => ({ + name: `B${index + 1}`, + fps: video.fps || 8, + frames: video.frames || [], + index + })); + + this.maxPairs = Math.max(this.videosA.length, this.videosB.length); + this.currentPairIndex = 0; + this.maxBatchPages = Math.ceil(this.maxPairs / this.pairsPerPage); + this.currentBatchPage = 0; + + const videoA = this.videosA[0] || null; + const videoB = this.videosB[0] || null; + this.framesA = videoA ? videoA.frames : []; + this.framesB = videoB ? videoB.frames : []; + this.fps = (videoA?.fps) || (videoB?.fps) || 8; + + this.loadedFramesA = {}; + this.loadedFramesB = {}; + this._value = v; + + if (this.framesA.length > 0 || this.framesB.length > 0) { + this.preloadInitialFrames(); + } + + if (this.node?.updateControlsVisibility) { + this.node.updateControlsVisibility(); + } + this.node.setDirtyCanvas(true, false); + } + + get value(): VideoWidgetValue { + return this._value || { video_data: [] }; + } + + private preloadInitialFrames(): void { + this.isInitialLoading = true; + this.loadedFrameCount = 0; + const bufferSize = Math.min(5, Math.max(this.framesA.length, this.framesB.length)); + this.targetLoadCount = Math.min(bufferSize, this.framesA.length) + Math.min(bufferSize, this.framesB.length); + + if (this.initialLoadTimer) clearTimeout(this.initialLoadTimer); + + for (let i = 0; i < bufferSize; i++) { + if (this.framesA.length > i) this.loadFrame(this.framesA[i], "A"); + if (this.framesB.length > i) this.loadFrame(this.framesB[i], "B"); + } + + this.initialLoadTimer = setTimeout(() => { + this.isInitialLoading = false; + if (this.pendingCanvasUpdate) { + this.node.setDirtyCanvas(true, false); + this.pendingCanvasUpdate = false; + } + }, 1000); + } + + private loadFrame(frameData: FrameData, videoId: string): LoadingImage | null { + if (!frameData) return null; + + const cacheKey = `${videoId}_${frameData.frame_index}`; + const cache = videoId === "A" ? this.loadedFramesA : this.loadedFramesB; + + if (cache[cacheKey]?.complete && !cache[cacheKey].failed) return cache[cacheKey]; + if (cache[cacheKey]?.isLoading || cache[cacheKey]?.queued) return cache[cacheKey]; + + if (Object.keys(cache).length > 10) this.cleanupFrameCache(cache); + + if (!this.loadingQueue.find(item => item.cacheKey === cacheKey)) { + this.loadingQueue.push({ frameData, videoId, cacheKey, retryCount: 0 }); + } + + if (!cache[cacheKey]) { + const img = new Image() as LoadingImage; + img.queued = true; + cache[cacheKey] = img; + } + + this.processLoadingQueue(); + return cache[cacheKey]; + } + + private processLoadingQueue(): void { + if (this.loadingInProgress) return; + this.loadingInProgress = true; + + while (this.loadingQueue.length > 0 && this.activeLoads.size < this.maxConcurrentLoads) { + const item = this.loadingQueue.shift(); + if (item) this.loadFrameImmediate(item); + } + + this.loadingInProgress = false; + + if (this.loadingQueue.length > 0) { + setTimeout(() => this.processLoadingQueue(), 100); + } + } + + private loadFrameImmediate(queueItem: LoadQueueItem): void { + const { frameData, videoId, cacheKey, retryCount } = queueItem; + const cache = videoId.startsWith("A") ? this.loadedFramesA : this.loadedFramesB; + + this.activeLoads.add(cacheKey); + + const img = (cache[cacheKey] || new Image()) as LoadingImage; + img.isLoading = true; + img.queued = false; + img.failed = false; + + img.onload = () => { + img.isLoading = false; + this.activeLoads.delete(cacheKey); + + if (this.isInitialLoading) { + this.loadedFrameCount++; + if (this.loadedFrameCount >= this.targetLoadCount || this.loadedFrameCount >= 2) { + this.isInitialLoading = false; + if (this.initialLoadTimer) clearTimeout(this.initialLoadTimer); + this.node.setDirtyCanvas(true, false); + } else { + this.pendingCanvasUpdate = true; + } + } else { + this.debouncedCanvasUpdate(); + } + + setTimeout(() => this.processLoadingQueue(), 10); + }; + + img.onerror = () => { + img.isLoading = false; + img.failed = true; + this.activeLoads.delete(cacheKey); + + if (retryCount < this.retryAttempts) { + setTimeout(() => { + this.loadingQueue.unshift({ ...queueItem, retryCount: retryCount + 1 }); + this.processLoadingQueue(); + }, 1000 * (retryCount + 1)); + } else { + delete cache[cacheKey]; + } + + setTimeout(() => this.processLoadingQueue(), 10); + }; + + setTimeout(() => { + if (!img.failed) { + img.src = frameData.data_url || imageDataToUrl(frameData); + } + }, 50); + + cache[cacheKey] = img; + } + + private cleanupFrameCache(cache: Record): void { + const keys = Object.keys(cache); + const toRemove = keys.slice(0, Math.floor(keys.length / 2)); + toRemove.forEach(key => { + if (cache[key] && !cache[key].isLoading) delete cache[key]; + }); + } + + private debouncedCanvasUpdate(): void { + if (this.canvasUpdateTimer) clearTimeout(this.canvasUpdateTimer); + this.canvasUpdateTimer = setTimeout(() => { + this.node.setDirtyCanvas(true, false); + }, 16); + } + + private getFrameImageForIndex(index: number, videoId: string): LoadingImage | null { + const frames = videoId === "A" ? this.framesA : this.framesB; + if (!frames.length || index < 0 || index >= frames.length) return null; + return this.loadFrame(frames[index], videoId); + } + + draw(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, y: number, height: number): void { + this._drawCallCount++; + this.y = y; + this.last_y = y; + + const nodeHeight = node.size[1]; + const availableHeight = nodeHeight - y - 10; + const mode = (node as ComparerNode).properties?.comparer_mode || "Playback"; + + switch (mode) { + case "Side-by-Side": this.drawSideBySideMode(ctx, y, width, availableHeight); break; + case "Stacked": this.drawStackedMode(ctx, y, width, availableHeight); break; + case "Slider": this.drawSliderMode(ctx, y, width, availableHeight); break; + case "Onion Skin": this.drawOnionSkinMode(ctx, y, width, availableHeight); break; + case "Sync Compare": this.drawSyncCompareMode(ctx, y, width, availableHeight); break; + case "Grid": this.drawGridMode(ctx, y, width, availableHeight); break; + case "Batch": this.drawBatchMode(ctx, y, width, availableHeight); break; + default: this.drawPlaybackMode(ctx, y, width, availableHeight); break; + } + + if (["Grid", "Batch"].includes(mode) && this.maxPairs > 1) { + this.drawBatchControls(ctx, y, width, availableHeight); + } else { + this.drawPlaybackControls(ctx, y + availableHeight - 45, width); + } + } + + private drawPlaybackMode(ctx: CanvasRenderingContext2D, y: number, width: number, availableHeight: number): void { + const videoId = this.node.properties?.selected_video || "A"; + const videoFrames = videoId === "A" ? this.framesA : this.framesB; + + if (!videoFrames.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight - 45, videoId); return; } + + const currentFrame = Math.min(this.currentFrameIndex, videoFrames.length - 1); + const frameImg = this.getFrameImageForIndex(currentFrame, videoId); + + if (this.isInitialLoading && (!frameImg || !frameImg.complete)) { + this.drawLoadingMessage(ctx, y, width, availableHeight - 45); + } else if (frameImg?.complete && !frameImg.failed) { + this.drawFrame(ctx, frameImg, y, width, availableHeight - 45); + this.drawFrameCounter(ctx, y + 10, width, currentFrame + 1, videoFrames.length, videoId); + } else { + this.drawLoadingMessage(ctx, y, width, availableHeight - 45); + } + } + + private drawSideBySideMode(ctx: CanvasRenderingContext2D, y: number, width: number, availableHeight: number): void { + const halfWidth = width / 2; + if (!this.framesA.length && !this.framesB.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, "both"); return; } + + const currentFrameA = Math.min(this.currentFrameIndex, this.framesA.length - 1); + const currentFrameB = Math.min(this.currentFrameIndex, this.framesB.length - 1); + + if (this.framesA.length) { + const frameImgA = this.getFrameImageForIndex(currentFrameA, "A"); + if (!this.isInitialLoading && frameImgA?.complete) { + this.drawFrameInRegion(ctx, frameImgA, y, 0, halfWidth, availableHeight); + this.drawFrameCounter(ctx, y + 10, halfWidth, currentFrameA + 1, this.framesA.length, "A"); + } else { + this.drawLoadingMessage(ctx, y, halfWidth, availableHeight, 0); + } + } else { + this.drawNoVideoMessage(ctx, y, halfWidth, availableHeight, "A", 0); + } + + if (this.framesB.length) { + const frameImgB = this.getFrameImageForIndex(currentFrameB, "B"); + if (!this.isInitialLoading && frameImgB?.complete) { + this.drawFrameInRegion(ctx, frameImgB, y, halfWidth, halfWidth, availableHeight); + this.drawFrameCounter(ctx, y + 10, halfWidth, currentFrameB + 1, this.framesB.length, "B", halfWidth); + } else { + this.drawLoadingMessage(ctx, y, halfWidth, availableHeight, halfWidth); + } + } else { + this.drawNoVideoMessage(ctx, y, halfWidth, availableHeight, "B", halfWidth); + } + + ctx.beginPath(); + ctx.moveTo(halfWidth, y); + ctx.lineTo(halfWidth, y + availableHeight - 45); + ctx.strokeStyle = "rgba(255,255,255,0.5)"; + ctx.lineWidth = 1; + ctx.stroke(); + } + + private drawStackedMode(ctx: CanvasRenderingContext2D, y: number, width: number, availableHeight: number): void { + const halfHeight = (availableHeight - 45) / 2; + if (!this.framesA.length && !this.framesB.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, "both"); return; } + + const currentFrameA = Math.min(this.currentFrameIndex, this.framesA.length - 1); + const currentFrameB = Math.min(this.currentFrameIndex, this.framesB.length - 1); + + if (this.framesA.length) { + const frameImgA = this.getFrameImageForIndex(currentFrameA, "A"); + if (frameImgA?.complete) { + this.drawFrameInRegion(ctx, frameImgA, y, 0, width, halfHeight); + this.drawFrameCounter(ctx, y + 10, width, currentFrameA + 1, this.framesA.length, "A"); + } else { this.drawLoadingMessage(ctx, y, width, halfHeight); } + } else { this.drawNoVideoMessage(ctx, y, width, halfHeight, "A"); } + + if (this.framesB.length) { + const frameImgB = this.getFrameImageForIndex(currentFrameB, "B"); + if (frameImgB?.complete) { + this.drawFrameInRegion(ctx, frameImgB, y + halfHeight, 0, width, halfHeight); + this.drawFrameCounter(ctx, y + halfHeight + 10, width, currentFrameB + 1, this.framesB.length, "B"); + } else { this.drawLoadingMessage(ctx, y + halfHeight, width, halfHeight); } + } else { this.drawNoVideoMessage(ctx, y + halfHeight, width, halfHeight, "B"); } + + ctx.beginPath(); + ctx.moveTo(0, y + halfHeight); + ctx.lineTo(width, y + halfHeight); + ctx.strokeStyle = "rgba(255,255,255,0.5)"; + ctx.lineWidth = 1; + ctx.stroke(); + } + + private drawSliderMode(ctx: CanvasRenderingContext2D, y: number, width: number, availableHeight: number): void { + if (!this.framesA.length && !this.framesB.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, "both"); return; } + + const currentFrameA = Math.min(this.currentFrameIndex, this.framesA.length - 1); + const currentFrameB = Math.min(this.currentFrameIndex, this.framesB.length - 1); + + if (this.framesA.length) { + const frameImgA = this.getFrameImageForIndex(currentFrameA, "A"); + if (frameImgA?.complete) { + this.drawFrame(ctx, frameImgA, y, width, availableHeight - 45); + this.drawFrameCounter(ctx, y + 10, width, currentFrameA + 1, this.framesA.length, "A"); + } else { this.drawLoadingMessage(ctx, y, width, availableHeight - 45); } + } + + if (this.framesB.length && this.node.isPointerOver) { + const frameImgB = this.getFrameImageForIndex(currentFrameB, "B"); + if (frameImgB?.complete) { + const sliderX = this.node.pointerOverPos?.[0] || width / 2; + ctx.save(); + ctx.beginPath(); + ctx.rect(0, y, sliderX, availableHeight - 45); + ctx.clip(); + this.drawFrame(ctx, frameImgB, y, width, availableHeight - 45); + ctx.restore(); + + ctx.beginPath(); + ctx.moveTo(sliderX, y); + ctx.lineTo(sliderX, y + availableHeight - 45); + ctx.strokeStyle = "rgba(255,255,255,0.8)"; + ctx.lineWidth = 2; + ctx.stroke(); + + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(sliderX + 5, y + 10, 20, 20); + ctx.fillStyle = "white"; + ctx.font = "12px Arial"; + ctx.textAlign = "center"; + ctx.fillText("B", sliderX + 15, y + 24); + } + } + } + + private drawOnionSkinMode(ctx: CanvasRenderingContext2D, y: number, width: number, availableHeight: number): void { + if (!this.framesA.length && !this.framesB.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, "both"); return; } + + const currentFrameA = Math.min(this.currentFrameIndex, this.framesA.length - 1); + const currentFrameB = Math.min(this.currentFrameIndex, this.framesB.length - 1); + const opacity = this.node.properties?.onionSkinOpacity || 0.5; + + if (this.framesA.length) { + const frameImgA = this.getFrameImageForIndex(currentFrameA, "A"); + if (frameImgA?.complete) { + this.drawFrame(ctx, frameImgA, y, width, availableHeight - 45); + this.drawFrameCounter(ctx, y + 10, width, currentFrameA + 1, this.framesA.length, "A"); + } else { this.drawLoadingMessage(ctx, y, width, availableHeight - 45); } + } + + if (this.framesB.length) { + const frameImgB = this.getFrameImageForIndex(currentFrameB, "B"); + if (frameImgB?.complete) { + ctx.save(); + ctx.globalAlpha = opacity; + this.drawFrame(ctx, frameImgB, y, width, availableHeight - 45); + ctx.restore(); + + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(width - 30, y + 10, 20, 20); + ctx.fillStyle = "white"; + ctx.font = "12px Arial"; + ctx.textAlign = "center"; + ctx.fillText("B", width - 20, y + 24); + } + } + } + + private drawSyncCompareMode(ctx: CanvasRenderingContext2D, y: number, width: number, availableHeight: number): void { + const selectedVideo = this.node.selectedVideo || "A"; + const frames = selectedVideo === "A" ? this.framesA : this.framesB; + + if (!frames.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, selectedVideo); return; } + + const currentFrame = Math.min(this.currentFrameIndex, frames.length - 1); + const frameImg = this.getFrameImageForIndex(currentFrame, selectedVideo); + + if (frameImg?.complete) { + this.drawFrame(ctx, frameImg, y, width, availableHeight - 45); + this.drawFrameCounter(ctx, y + 10, width, currentFrame + 1, frames.length, selectedVideo); + + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(width / 2 - 60, y + availableHeight - 65, 120, 24); + ctx.fillStyle = "white"; + ctx.font = "12px Arial"; + ctx.textAlign = "center"; + ctx.fillText("Click to toggle A/B", width / 2, y + availableHeight - 49); + } else { + this.drawLoadingMessage(ctx, y, width, availableHeight - 45); + } + } + + private drawGridMode(ctx: CanvasRenderingContext2D, y: number, width: number, availableHeight: number): void { + // Simplified grid - draw first 4 pairs in 2x2 grid + const cols = 2, rows = 2; + const cellWidth = width / cols, cellHeight = (availableHeight - 30) / rows; + + for (let i = 0; i < Math.min(this.maxPairs, 4); i++) { + const col = i % cols, row = Math.floor(i / cols); + const x = col * cellWidth, cellY = y + row * cellHeight; + + const videoA = this.videosA[i], videoB = this.videosB[i]; + if (videoA?.frames.length) { + const frame = this.loadFrame(videoA.frames[this.currentFrameIndex % videoA.frames.length], `A_${i}`); + if (frame?.complete) this.drawFrameInRegion(ctx, frame, cellY, x, cellWidth / 2, cellHeight); + } + if (videoB?.frames.length) { + const frame = this.loadFrame(videoB.frames[this.currentFrameIndex % videoB.frames.length], `B_${i}`); + if (frame?.complete) this.drawFrameInRegion(ctx, frame, cellY, x + cellWidth / 2, cellWidth / 2, cellHeight); + } + } + } + + private drawBatchMode(ctx: CanvasRenderingContext2D, y: number, width: number, availableHeight: number): void { + const startPair = this.currentBatchPage * this.pairsPerPage; + const pairHeight = (availableHeight - 30) / this.pairsPerPage; + + for (let i = 0; i < this.pairsPerPage && startPair + i < this.maxPairs; i++) { + const pairIdx = startPair + i; + const pairY = y + i * pairHeight; + + const videoA = this.videosA[pairIdx], videoB = this.videosB[pairIdx]; + if (videoA?.frames.length) { + const frame = this.loadFrame(videoA.frames[this.currentFrameIndex % videoA.frames.length], `A_${pairIdx}`); + if (frame?.complete) this.drawFrameInRegion(ctx, frame, pairY, 0, width / 2, pairHeight); + } + if (videoB?.frames.length) { + const frame = this.loadFrame(videoB.frames[this.currentFrameIndex % videoB.frames.length], `B_${pairIdx}`); + if (frame?.complete) this.drawFrameInRegion(ctx, frame, pairY, width / 2, width / 2, pairHeight); + } + } + } + + private drawFrame(ctx: CanvasRenderingContext2D, img: HTMLImageElement, y: number, width: number, availableHeight: number): void { + if (!img?.complete) return; + const imageAspect = img.naturalWidth / img.naturalHeight; + const canvasAspect = width / availableHeight; + let drawWidth: number, drawHeight: number, offsetX = 0, offsetY = 0; + + if (imageAspect > canvasAspect) { + drawWidth = width; + drawHeight = width / imageAspect; + offsetY = (availableHeight - drawHeight) / 2; + } else { + drawHeight = availableHeight; + drawWidth = availableHeight * imageAspect; + offsetX = (width - drawWidth) / 2; + } + + ctx.drawImage(img, offsetX, y + offsetY, drawWidth, drawHeight); + } + + private drawFrameInRegion(ctx: CanvasRenderingContext2D, img: HTMLImageElement, y: number, x: number, regionWidth: number, regionHeight: number): void { + if (!img?.complete) return; + const imageAspect = img.naturalWidth / img.naturalHeight; + const regionAspect = regionWidth / regionHeight; + let drawWidth: number, drawHeight: number, offsetX = 0, offsetY = 0; + + if (imageAspect > regionAspect) { + drawWidth = regionWidth; + drawHeight = regionWidth / imageAspect; + offsetY = (regionHeight - drawHeight) / 2; + } else { + drawHeight = regionHeight; + drawWidth = regionHeight * imageAspect; + offsetX = (regionWidth - drawWidth) / 2; + } + + ctx.drawImage(img, x + offsetX, y + offsetY, drawWidth, drawHeight); + } + + private drawFrameCounter(ctx: CanvasRenderingContext2D, y: number, width: number, current: number, total: number, videoId: string, offsetX = 0): void { + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(offsetX + 10, y, 80, 24); + ctx.fillStyle = "white"; + ctx.font = "12px Arial"; + ctx.textAlign = "left"; + ctx.fillText(`${videoId}: ${current}/${total}`, offsetX + 15, y + 16); + } + + private drawNoVideoMessage(ctx: CanvasRenderingContext2D, y: number, width: number, height: number, videoId: string, offsetX = 0): void { + ctx.fillStyle = "rgba(0,0,0,0.3)"; + ctx.fillRect(offsetX, y, width, height); + ctx.fillStyle = "white"; + ctx.font = "14px Arial"; + ctx.textAlign = "center"; + ctx.fillText(videoId === "both" ? "No videos available" : `No video ${videoId} available`, offsetX + width / 2, y + height / 2); + } + + private drawLoadingMessage(ctx: CanvasRenderingContext2D, y: number, width: number, height: number, offsetX = 0): void { + ctx.fillStyle = "rgba(0,0,0,0.3)"; + ctx.fillRect(offsetX, y, width, height); + ctx.fillStyle = "white"; + ctx.font = "14px Arial"; + ctx.textAlign = "center"; + ctx.fillText("Loading frames...", offsetX + width / 2, y + height / 2); + } + + private drawPlaybackControls(ctx: CanvasRenderingContext2D, y: number, width: number): void { + const controlHeight = 45; + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(0, y, width, controlHeight); + + const buttonSize = 36; + const playPauseX = 12; + const sliderStart = playPauseX + buttonSize + 8; + const sliderWidth = width - sliderStart - 68; + + ctx.fillStyle = "rgba(100,100,100,0.8)"; + const buttonY = y + (controlHeight - buttonSize) / 2; + ctx.beginPath(); + (ctx as any).roundRect(playPauseX, buttonY, buttonSize, buttonSize, 4); + ctx.fill(); + + ctx.fillStyle = "white"; + ctx.font = "20px Arial"; + ctx.textAlign = "center"; + ctx.fillText(this.isPlaying ? "⏸" : "▶", playPauseX + buttonSize / 2, y + controlHeight / 2 + 7); + + const trackHeight = 8; + ctx.fillStyle = "rgba(60,60,60,0.8)"; + ctx.beginPath(); + (ctx as any).roundRect(sliderStart, y + controlHeight / 2 - trackHeight / 2, sliderWidth, trackHeight, trackHeight / 2); + ctx.fill(); + + const totalFrames = Math.max(this.framesA.length, this.framesB.length); + if (totalFrames > 0) { + const progress = this.currentFrameIndex / (totalFrames - 1); + const scrubberPos = sliderStart + progress * sliderWidth; + const scrubberRadius = 10; + + ctx.fillStyle = "rgba(0,0,0,0.3)"; + ctx.beginPath(); + ctx.arc(scrubberPos + 1, y + controlHeight / 2 + 1, scrubberRadius, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = "white"; + ctx.beginPath(); + ctx.arc(scrubberPos, y + controlHeight / 2, scrubberRadius, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = "white"; + ctx.font = "14px Arial"; + ctx.textAlign = "right"; + ctx.fillText(`${this.currentFrameIndex + 1}/${totalFrames}`, width - 12, y + controlHeight / 2 + 5); + } + } + + private drawBatchControls(ctx: CanvasRenderingContext2D, y: number, width: number, availableHeight: number): void { + const controlY = y + availableHeight - 30; + ctx.save(); + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(0, controlY, width, 30); + ctx.fillStyle = "white"; + ctx.font = "12px Arial"; + ctx.textAlign = "left"; + ctx.fillText(`Frame: ${this.currentFrameIndex + 1}`, 10, controlY + 18); + + const buttonWidth = 60, buttonHeight = 20, buttonY = controlY + 5; + ctx.fillStyle = this.isPlaying ? "rgba(200,100,100,0.8)" : "rgba(100,200,100,0.8)"; + ctx.fillRect(80, buttonY, buttonWidth, buttonHeight); + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.fillText(this.isPlaying ? "⏸ Pause" : "▶ Play", 80 + buttonWidth / 2, buttonY + 14); + ctx.restore(); + } + + startPlayback(): void { + if (this.isPlaying) return; + this.isPlaying = true; + this.isInitialLoading = false; + this.lastFrameTime = performance.now(); + + const playbackLoop = (): void => { + const now = performance.now(); + if (now - this.lastFrameTime >= 1000 / this.fps) { + this.advanceFrame(); + this.lastFrameTime = now; + } + if (this.isPlaying) this.animationFrame = requestAnimationFrame(playbackLoop); + }; + + this.animationFrame = requestAnimationFrame(playbackLoop); + this.node.setDirtyCanvas(true, false); + } + + stopPlayback(): void { + if (!this.isPlaying) return; + this.isPlaying = false; + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + this.node.setDirtyCanvas(true, false); + } + + togglePlayback(): void { + this.isPlaying ? this.stopPlayback() : this.startPlayback(); + } + + advanceFrame(): void { + const totalFrames = Math.max(this.framesA.length, this.framesB.length); + if (totalFrames === 0) return; + this.currentFrameIndex = (this.currentFrameIndex + 1) % totalFrames; + this.node.setDirtyCanvas(true, false); + } + + previousFrame(): void { + const totalFrames = Math.max(this.framesA.length, this.framesB.length); + if (totalFrames === 0) return; + this.currentFrameIndex = (this.currentFrameIndex - 1 + totalFrames) % totalFrames; + this.node.setDirtyCanvas(true, false); + } + + nextFrame(): void { this.advanceFrame(); } + + mouse(event: MouseEvent, pos: [number, number], node: LGraphNode): boolean { + if (event.type !== "mousedown") return false; + + const mode = (node as ComparerNode).properties?.comparer_mode; + if (mode === "Sync Compare") { + (node as ComparerNode).selectedVideo = (node as ComparerNode).selectedVideo === "A" ? "B" : "A"; + node.setDirtyCanvas(true, false); + return true; + } + + // Check playback controls + const widgetY = this.y || 0; + const availableHeight = node.size[1] - widgetY - 10; + const controlsY = widgetY + availableHeight - 45; + + if (pos[1] >= controlsY && pos[1] <= controlsY + 45) { + if (pos[0] >= 12 && pos[0] <= 48) { + this.togglePlayback(); + return true; + } + + const sliderStart = 56, sliderWidth = node.size[0] - sliderStart - 68; + if (pos[0] >= sliderStart && pos[0] <= sliderStart + sliderWidth) { + const totalFrames = Math.max(this.framesA.length, this.framesB.length); + if (totalFrames > 0) { + const progress = (pos[0] - sliderStart) / sliderWidth; + this.currentFrameIndex = Math.floor(progress * (totalFrames - 1)); + node.setDirtyCanvas(true, false); + return true; + } + } + } + + return false; + } + + computeSize(width: number): [number, number] { + return [width, 300]; + } + + onRemoved(): void { + this.stopPlayback(); + if (this.initialLoadTimer) clearTimeout(this.initialLoadTimer); + if (this.canvasUpdateTimer) clearTimeout(this.canvasUpdateTimer); + this.loadedFramesA = {}; + this.loadedFramesB = {}; + } +} + +// === COMFYUI EXTENSION REGISTRATION === + +(app as any).registerExtension({ + name: "VideoComparer", + async beforeRegisterNodeDef(nodeType: any, nodeData: ComfyNodeData, _app: ComfyApp) { + if (nodeData.name !== "Video Comparer" && nodeData.name !== "VideoComparer") return; + + nodeType.prototype.properties = nodeType.prototype.properties || {}; + nodeType.prototype.properties.comparer_mode = "Playback"; + nodeType.prototype.properties.selected_video = "A"; + nodeType.prototype.properties.onionSkinOpacity = 0.5; + nodeType.prototype.properties.user_resized = false; + + const origOnDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (this: ComparerNode, ctx: CanvasRenderingContext2D) { + if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments); + drawGradientTitle(this, ctx); + }; + + const origOnRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function (this: ComparerNode) { + if (origOnRemoved) origOnRemoved.apply(this, arguments); + if (this.videoComparerWidget) this.videoComparerWidget.onRemoved(); + CACHE.titleCanvas = null; + CACHE.titleCtx = null; + }; + + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function (this: ComparerNode) { + if (origOnNodeCreated) origOnNodeCreated.apply(this, arguments); + + this.properties = this.properties || {} as ComparerProperties; + this.properties.comparer_mode = this.properties.comparer_mode || "Playback"; + this.properties.selected_video = this.properties.selected_video || "A"; + this.properties.onionSkinOpacity = this.properties.onionSkinOpacity ?? 0.5; + + this.isPointerOver = false; + this.pointerOverPos = [0, 0]; + this.selectedVideo = "A"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.layoutWidget = this.addWidget("combo", "Comparison Mode", this.properties.comparer_mode, ((value: string) => { + this.properties.comparer_mode = value; + if (this.updateControlsVisibility) this.updateControlsVisibility(); + this.setDirtyCanvas(true, false); + }) as any, { values: ["Playback", "Side-by-Side", "Stacked", "Slider", "Onion Skin", "Sync Compare"] }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.videoSelectorWidget = this.addWidget("combo", "Video", this.properties.selected_video, ((value: string) => { + this.properties.selected_video = value; + this.selectedVideo = value; + this.setDirtyCanvas(true, false); + }) as any, { values: ["A", "B"] }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.onionSkinOpacitySlider = this.addWidget("slider", "Opacity B", this.properties.onionSkinOpacity, ((value: number) => { + this.properties.onionSkinOpacity = value; + this.setDirtyCanvas(true, false); + }) as any, { min: 0.0, max: 1.0, step: 0.01 }); + + this.videoComparerWidget = this.addCustomWidget(new VideoComparerWidget("video_comparer", this)) as any as VideoComparerWidget; + + if (this.updateControlsVisibility) this.updateControlsVisibility(); + + const initialSize = this.computeSize?.() || [400, 300]; + if (this.setSize) this.setSize([Math.max(400, initialSize[0]), Math.max(300, initialSize[1])]); + this.setDirtyCanvas(true, true); + }; + + nodeType.prototype.updateControlsVisibility = function (this: ComparerNode) { + const mode = this.properties.comparer_mode; + if (this.videoSelectorWidget) (this.videoSelectorWidget as any).hidden = mode !== "Playback"; + if (this.onionSkinOpacitySlider) (this.onionSkinOpacitySlider as any).hidden = mode !== "Onion Skin"; + }; + + const origComputeSize = nodeType.prototype.computeSize; + nodeType.prototype.computeSize = function (this: ComparerNode, out?: [number, number]): [number, number] { + const size = origComputeSize ? origComputeSize.apply(this, arguments) : [400, 300]; + if (this.videoComparerWidget) { + const widgetSize = this.videoComparerWidget.computeSize(size[0]); + let extraHeight = 60; + if (this.properties.comparer_mode === "Playback" || this.properties.comparer_mode === "Onion Skin") extraHeight += 30; + size[1] = Math.max(size[1], widgetSize[1] + extraHeight); + } + return size; + }; + + const origOnExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (this: ComparerNode, message: any) { + if (origOnExecuted) origOnExecuted.apply(this, arguments); + + if (!message || typeof message !== 'object') return message; + + let videoData = message.ui?.video_data || message.video_data; + if (videoData && this.videoComparerWidget) { + this.videoComparerWidget.value = { + video_data: videoData, + difference_data: message.ui?.difference_data || {}, + histogram_data: message.ui?.histogram_data || [] + }; + this.setDirtyCanvas(true, true); + } + + return message; + }; + + nodeType.prototype.onMouseDown = function (this: ComparerNode, event: MouseEvent, pos: [number, number]) { + return this.videoComparerWidget?.mouse(event, pos, this) || false; + }; + + nodeType.prototype.onMouseEnter = function (this: ComparerNode) { + this.isPointerOver = true; + this.setDirtyCanvas(true, false); + }; + + nodeType.prototype.onMouseLeave = function (this: ComparerNode) { + this.isPointerOver = false; + this.setDirtyCanvas(true, false); + }; + + nodeType.prototype.onMouseMove = function (this: ComparerNode, event: MouseEvent, pos: [number, number]) { + this.pointerOverPos = pos; + if (this.properties.comparer_mode === "Slider") this.setDirtyCanvas(true, false); + return false; + }; + + nodeType.prototype.onKeyDown = function (this: ComparerNode, event: KeyboardEvent) { + if (!this.videoComparerWidget) return false; + switch (event.key) { + case "ArrowLeft": this.videoComparerWidget.previousFrame(); return true; + case "ArrowRight": this.videoComparerWidget.nextFrame(); return true; + case " ": this.videoComparerWidget.togglePlayback(); event.preventDefault(); return true; + } + return false; + }; + + const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (this: ComparerNode, _: any, options: ContextMenuItem[]) { + if (origGetExtraMenuOptions) origGetExtraMenuOptions.apply(this, arguments); + + options.push(null as any); + const currentMode = this.properties.comparer_mode || "Playback"; + const modes = ["Playback", "Side-by-Side", "Stacked", "Slider", "Onion Skin", "Sync Compare"]; + + options.push({ + content: "Comparison Mode", + has_submenu: true, + submenu: { + options: modes.map(mode => ({ + content: mode === currentMode ? `✓ ${mode}` : mode, + callback: () => { + this.properties.comparer_mode = mode; + if (this.layoutWidget) this.layoutWidget.value = mode; + if (this.updateControlsVisibility) this.updateControlsVisibility(); + this.setDirtyCanvas(true, false); + } + })) + } + }); + + options.push({ + content: "Reset to Default Size", + callback: () => { + this.properties.user_resized = false; + if (this.setSize && this.computeSize) this.setSize(this.computeSize()); + this.setDirtyCanvas(true, false); + } + }); + }; + + console.log("[VideoComparer] Node setup complete"); + } +} as ComfyExtension); diff --git a/web/tests/integration.test.ts b/web/tests/integration.test.ts new file mode 100644 index 0000000..94cc61a --- /dev/null +++ b/web/tests/integration.test.ts @@ -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]); + }); + }); +}); diff --git a/web/tests/matrix_button.test.ts b/web/tests/matrix_button.test.ts new file mode 100644 index 0000000..ca4d6a1 --- /dev/null +++ b/web/tests/matrix_button.test.ts @@ -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(); + }); + }); +}); diff --git a/web/tests/mocks/comfyui.ts b/web/tests/mocks/comfyui.ts new file mode 100644 index 0000000..a5a5a60 --- /dev/null +++ b/web/tests/mocks/comfyui.ts @@ -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; +} + +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; + 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 { + 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; + properties: Record; + widgets: unknown[]; + widgets_values: unknown[]; + inputs: unknown[]; + outputs: unknown[]; + graph: unknown; + setDirtyCanvas: ReturnType; + triggerSlot: ReturnType; + addWidget: ReturnType; + addCustomWidget: ReturnType; + computeSize: ReturnType; + onNodeCreated?: () => void; + onRemoved?: () => void; + onConfigure?: (info: unknown) => void; + onDrawForeground?: (ctx: CanvasRenderingContext2D) => void; + onResize?: (size: [number, number]) => void; +} diff --git a/web/tests/noise_visualizer.test.ts b/web/tests/noise_visualizer.test.ts new file mode 100644 index 0000000..04fb5c4 --- /dev/null +++ b/web/tests/noise_visualizer.test.ts @@ -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(); + }); + }); +}); diff --git a/web/tests/setup.ts b/web/tests/setup.ts new file mode 100644 index 0000000..02e42a3 --- /dev/null +++ b/web/tests/setup.ts @@ -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; diff --git a/web/tests/shader_rendering.test.ts b/web/tests/shader_rendering.test.ts new file mode 100644 index 0000000..0690ad9 --- /dev/null +++ b/web/tests/shader_rendering.test.ts @@ -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); + }); + }); +}); diff --git a/web/types/comfyui.js b/web/types/comfyui.js new file mode 100644 index 0000000..25b68d2 --- /dev/null +++ b/web/types/comfyui.js @@ -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 \ No newline at end of file diff --git a/web/types/comfyui.js.map b/web/types/comfyui.js.map new file mode 100644 index 0000000..48c2b16 --- /dev/null +++ b/web/types/comfyui.js.map @@ -0,0 +1 @@ +{"version":3,"file":"comfyui.js","sourceRoot":"","sources":["comfyui.ts"],"names":[],"mappings":"AAAA;;;GAGG"} \ No newline at end of file diff --git a/web/types/comfyui.ts b/web/types/comfyui.ts new file mode 100644 index 0000000..f75ffc5 --- /dev/null +++ b/web/types/comfyui.ts @@ -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; +} + +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; + + /** + * Called when the app is set up + */ + setup?(app: ComfyApp): void | Promise; + + /** + * 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; + optional?: Record; + }; + output?: string[]; + output_name?: string[]; +} + +export type ComfyInputSpec = [string | string[], Record?]; + +export interface ComfyApi { + /** + * Fetch from the ComfyUI API + */ + fetchApi(route: string, options?: RequestInit): Promise; + + /** + * 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 }; diff --git a/web/types/litegraph.js b/web/types/litegraph.js new file mode 100644 index 0000000..9294187 --- /dev/null +++ b/web/types/litegraph.js @@ -0,0 +1,6 @@ +/** + * Type declarations for LiteGraph.js + * Based on LiteGraph library used by ComfyUI + */ +export {}; +//# sourceMappingURL=litegraph.js.map \ No newline at end of file diff --git a/web/types/litegraph.js.map b/web/types/litegraph.js.map new file mode 100644 index 0000000..6ddb9e4 --- /dev/null +++ b/web/types/litegraph.js.map @@ -0,0 +1 @@ +{"version":3,"file":"litegraph.js","sourceRoot":"","sources":["litegraph.ts"],"names":[],"mappings":"AAAA;;;GAGG"} \ No newline at end of file diff --git a/web/types/litegraph.ts b/web/types/litegraph.ts new file mode 100644 index 0000000..d2492cb --- /dev/null +++ b/web/types/litegraph.ts @@ -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; + properties: Record; + 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; + properties?: Record; + 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; + + /** + * 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; + 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 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; + }; +} diff --git a/web/video_comparer.js b/web/video_comparer.js index 78b90e5..af91fc0 100644 --- a/web/video_comparer.js +++ b/web/video_comparer.js @@ -1,12 +1,13 @@ -// VideoComparer.js -// Implementation based on AdvancedImageComparer but adapted for video comparison - +/** + * VideoComparer.ts + * Video comparison widget for ComfyUI with multiple display modes + */ +// @ts-ignore - Runtime ComfyUI import import { app } from "../../../scripts/app.js"; +// @ts-ignore - Runtime ComfyUI import import { api } from "../../../scripts/api.js"; - console.log("VideoComparer module loaded"); - -// Cache for rendering optimization +// === CACHE FOR RENDERING OPTIMIZATION === const CACHE = { titleCanvas: null, titleCtx: null, @@ -14,14 +15,14 @@ const CACHE = { lastHeight: 0, lastTime: 0, frameCount: 0, - frameSkip: 2, // Only update animation every X frames + frameSkip: 2, collapsed: { canvas: null, ctx: null, lastWidth: 0 } }; - +// === HELPER FUNCTIONS === function imageDataToUrl(data) { if (!data || !data.filename) { console.error("[VideoComparer] Invalid image data", data); @@ -29,604 +30,354 @@ function imageDataToUrl(data) { } return api.apiURL(`/view?filename=${encodeURIComponent(data.filename)}&type=${encodeURIComponent(data.type || "")}&subfolder=${encodeURIComponent(data.subfolder || "")}${app.getPreviewFormatParam()}${app.getRandParam()}`); } - -/** - * Draws a custom golden eyeball using canvas drawing commands - * @param {CanvasRenderingContext2D} ctx - The canvas context - * @param {number} centerX - X center position - * @param {number} centerY - Y center position - * @param {number} size - Size of the eyeball - * @param {number} shimmerPosition - Position of the shimmer effect (0-1) - */ function drawGoldenEyeball(ctx, centerX, centerY, size, shimmerPosition) { - // Refined proportions for smaller, cleaner look - const eyeWidth = size * 1.6; - const eyeHeight = size * 1.0; - const irisRadius = size * 0.35; - const pupilRadius = size * 0.15; - + const eyeWidth = size * 1.6, eyeHeight = size * 1.0; + const irisRadius = size * 0.35, pupilRadius = size * 0.15; ctx.save(); - - // Create base golden gradient exactly like the mathematical formula - const baseGradient = ctx.createLinearGradient(0, centerY - size*0.7, 0, centerY + size*0.7); - baseGradient.addColorStop(0, "#B8860B"); // Darker gold - baseGradient.addColorStop(0.5, "#FFD700"); // Bright gold - baseGradient.addColorStop(1, "#B8860B"); // Darker gold - - // Create moving highlight effect - const highlightWidth = eyeWidth * 0.4; // Width of the highlight + 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 - ); - - // Create smooth highlight transition + 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 etched shadow for all outlines + // Draw etched shadows ctx.strokeStyle = "rgba(0,0,0,0.3)"; ctx.lineWidth = 1.5; ctx.lineCap = "round"; - - // Shadow for main eye outline ctx.beginPath(); - ctx.ellipse(centerX + 2, centerY + 2, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2); + ctx.ellipse(centerX + 2, centerY + 2, eyeWidth / 2, eyeHeight / 2, 0, 0, Math.PI * 2); ctx.stroke(); - - // Shadow for iris ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(centerX + 2, centerY + 2, irisRadius, 0, Math.PI * 2); ctx.stroke(); - - // Shadow for pupil ctx.beginPath(); ctx.arc(centerX + 2, centerY + 2, pupilRadius, 0, Math.PI * 2); ctx.stroke(); - - // Draw 6 eyelashes/rays around the eye (representing 6 comparison modes) - shadows first - const rayCount = 6; // Changed from 8 to 6 - const rayLength = size * 0.7; - ctx.lineWidth = 1; - + // Draw rays shadows + const rayCount = 6, 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); - + const startX = centerX + Math.cos(angle) * (eyeWidth / 2 + 1); + const startY = centerY + Math.sin(angle) * (eyeHeight / 2 + 1); + const endX = centerX + Math.cos(angle) * (eyeWidth / 2 + rayLength); + const endY = centerY + Math.sin(angle) * (eyeHeight / 2 + rayLength); ctx.beginPath(); ctx.moveTo(startX + 2, startY + 2); ctx.lineTo(endX + 2, endY + 2); ctx.stroke(); } - - // Now draw the golden base outlines + // Draw golden base ctx.strokeStyle = baseGradient; ctx.lineWidth = 1.5; - - // Main eye outline ctx.beginPath(); - ctx.ellipse(centerX, centerY, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2); + ctx.ellipse(centerX, centerY, eyeWidth / 2, eyeHeight / 2, 0, 0, Math.PI * 2); ctx.stroke(); - - // Iris outline ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(centerX, centerY, irisRadius, 0, Math.PI * 2); ctx.stroke(); - - // Pupil outline ctx.beginPath(); ctx.arc(centerX, centerY, pupilRadius, 0, Math.PI * 2); ctx.stroke(); - - // Draw 6 eyelashes/rays with base golden color for (let i = 0; i < rayCount; i++) { const angle = (i / rayCount) * Math.PI * 2; - const startX = centerX + Math.cos(angle) * (eyeWidth/2 + 1); - const startY = centerY + Math.sin(angle) * (eyeHeight/2 + 1); - const endX = centerX + Math.cos(angle) * (eyeWidth/2 + rayLength); - const endY = centerY + Math.sin(angle) * (eyeHeight/2 + rayLength); - + const startX = centerX + Math.cos(angle) * (eyeWidth / 2 + 1); + const startY = centerY + Math.sin(angle) * (eyeHeight / 2 + 1); + const endX = centerX + Math.cos(angle) * (eyeWidth / 2 + rayLength); + const endY = centerY + Math.sin(angle) * (eyeHeight / 2 + rayLength); ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(endX, endY); ctx.stroke(); } - - // Add subtle iris texture lines + // Iris texture ctx.lineWidth = 0.5; - for (let i = 0; i < 6; i++) { // Changed from 8 to 6 to match rays + for (let i = 0; i < 6; i++) { const angle = (i / 6) * Math.PI * 2; ctx.beginPath(); ctx.moveTo(centerX + Math.cos(angle) * pupilRadius * 1.1, centerY + Math.sin(angle) * pupilRadius * 1.1); ctx.lineTo(centerX + Math.cos(angle) * irisRadius * 0.9, centerY + Math.sin(angle) * irisRadius * 0.9); ctx.stroke(); } - - // Apply shimmer highlight effect to all outlines + // Shimmer effect ctx.strokeStyle = shimmerGradient; ctx.lineWidth = 1.5; - - // Shimmer on main eye outline ctx.beginPath(); - ctx.ellipse(centerX, centerY, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2); + ctx.ellipse(centerX, centerY, eyeWidth / 2, eyeHeight / 2, 0, 0, Math.PI * 2); ctx.stroke(); - - // Shimmer on iris - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(centerX, centerY, irisRadius, 0, Math.PI * 2); - ctx.stroke(); - - // Shimmer on pupil - ctx.beginPath(); - ctx.arc(centerX, centerY, pupilRadius, 0, Math.PI * 2); - ctx.stroke(); - - // Shimmer on 6 eyelashes/rays - for (let i = 0; i < rayCount; i++) { - const angle = (i / rayCount) * Math.PI * 2; - const startX = centerX + Math.cos(angle) * (eyeWidth/2 + 1); - const startY = centerY + Math.sin(angle) * (eyeHeight/2 + 1); - const endX = centerX + Math.cos(angle) * (eyeWidth/2 + rayLength); - const endY = centerY + Math.sin(angle) * (eyeHeight/2 + rayLength); - - ctx.beginPath(); - ctx.moveTo(startX, startY); - ctx.lineTo(endX, endY); - ctx.stroke(); - } - - // Add outline glow that follows the highlight - const glowIntensity = Math.max(0, 1 - Math.abs(centerX - (centerX + highlightX))/(eyeWidth/4)); - ctx.shadowColor = `rgba(255, 255, 200, ${glowIntensity * 0.3})`; - ctx.shadowBlur = 8; - ctx.shadowOffsetX = 0; - ctx.shadowOffsetY = 0; - - // Final glow pass on main outline - ctx.strokeStyle = baseGradient; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.ellipse(centerX, centerY, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.restore(); } - -/** - * Draws a gradient background with golden eyeball title - * @param {LGraphNode} node - The node to apply the gradient to - * @param {CanvasRenderingContext2D} ctx - The canvas context - */ function drawGradientTitle(node, ctx) { - // Get title area dimensions - const titleHeight = node.flags.collapsed ? 20 : 30; // Smaller height when collapsed - const width = node.flags.collapsed ? 190 : node.size[0]; // Smaller width when collapsed - const fullHeight = node.size[1]; // Get actual node height - const eyeballY = node.flags.collapsed ? titleHeight / 2 : 25; // Much closer to top - - // Eyeball size based on collapsed state (made smaller) + const 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; - - // Update animation frame counter (performance optimization) CACHE.frameCount = (CACHE.frameCount + 1) % (CACHE.frameSkip + 1); const shouldUpdateAnimation = CACHE.frameCount === 0; - - // Save current state ctx.save(); - - // Reset shadow properties for gradient drawing ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; - - // Create vertical black gradient for entire background that fills node height const gradient = ctx.createLinearGradient(0, 0, 0, fullHeight); - gradient.addColorStop(0, "#000000"); // Pure black at top - gradient.addColorStop(0.2, "#101010"); // Transition to very dark gray - gradient.addColorStop(1, "#101010"); // Very dark gray at bottom - - // Create smooth shimmer effect for eyeball - only calculate if animation should update - let shimmerPosition = 0.5; // Default middle position + gradient.addColorStop(0, "#000000"); + gradient.addColorStop(0.2, "#101010"); + gradient.addColorStop(1, "#101010"); + let shimmerPosition = 0.5; if (shouldUpdateAnimation) { - const time = Date.now() / 3000; // Faster time factor - shimmerPosition = (Math.sin(time) + 1) / 2; // Changed from cos to sin for left-to-right only - // Store for later use if needed + const time = Date.now() / 3000; + shimmerPosition = (Math.sin(time) + 1) / 2; CACHE.lastTime = time; - } else { - // Reuse last calculation for animation frames we're skipping + } + else { const time = CACHE.lastTime || Date.now() / 3000; shimmerPosition = (Math.sin(time) + 1) / 2; } - - // Add collapse button handler if (node.flags.collapsed) { - // If node is collapsed, adjust the title rendering ctx.fillStyle = gradient; ctx.fillRect(0, 0, width, titleHeight); - - // Draw custom golden eyeball for collapsed version drawGoldenEyeball(ctx, width / 2, titleHeight / 2, eyeballSize, shimmerPosition); - - // Skip the rest of the rendering when collapsed ctx.restore(); return; } - - // Draw background that fills the entire node ctx.fillStyle = gradient; - - // Use rounded rectangle for the background with rounded corners at the bottom - if (!node.flags.collapsed) { - const cornerRadius = 8; // Adjust radius as needed - - // Create path for rounded rectangle - ctx.beginPath(); - ctx.moveTo(0, 0); // Top-left corner (no rounding) - ctx.lineTo(width, 0); // Top-right corner (no rounding) - ctx.lineTo(width, fullHeight - cornerRadius); // Right edge before bottom-right corner - ctx.arcTo(width, fullHeight, width - cornerRadius, fullHeight, cornerRadius); // Bottom-right rounded corner - ctx.lineTo(cornerRadius, fullHeight); // Bottom edge before bottom-left corner - ctx.arcTo(0, fullHeight, 0, fullHeight - cornerRadius, cornerRadius); // Bottom-left rounded corner - ctx.lineTo(0, 0); // Left edge back to top - ctx.closePath(); - ctx.fill(); - } else { - // Keep regular rectangle for collapsed state - ctx.fillRect(0, 0, width, fullHeight); // No extra padding - } - - // Draw custom golden eyeball for expanded version + 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(); drawGoldenEyeball(ctx, width / 2, eyeballY, eyeballSize, shimmerPosition); - - // Restore context state ctx.restore(); } - +// === VIDEO COMPARER WIDGET CLASS === class VideoComparerWidget { constructor(name, node) { - console.log("[VideoComparer] Widget constructor called with name:", name, "node:", node); - - this.name = name; - this.node = node; this.type = "video_comparer"; - this.value = { video_data: [] }; - - // Initialize frame caches and tracking + this._value = { video_data: [] }; this.loadedFramesA = {}; this.loadedFramesB = {}; this.framesA = []; this.framesB = []; this.currentFrameIndex = 0; this.isPlaying = false; - this.playbackInterval = null; - - // Add loading queue management + this.animationFrame = null; + this.fps = 8; this.loadingQueue = []; this.activeLoads = new Set(); this.maxConcurrentLoads = 3; this.retryAttempts = 3; this.loadingInProgress = false; - - // Enhanced batch handling properties (similar to advanced_comparer.js) this.videosA = []; this.videosB = []; this.currentPairIndex = 0; this.maxPairs = 0; - this.animationFrame = null; - this.autoPlayEnabled = false; - this.autoPlaySpeed = 2000; // 2 seconds per pair - - // Batch pagination properties this.currentBatchPage = 0; - this.pairsPerPage = 2; // Show 2 pairs per page in batch mode (videos are larger than images) + this.pairsPerPage = 2; this.maxBatchPages = 0; - - console.log("[VideoComparer] Widget constructor complete"); + this.isInitialLoading = false; + this.loadedFrameCount = 0; + this.targetLoadCount = 0; + this.initialLoadTimer = null; + this.pendingCanvasUpdate = false; + this.lastFrameTime = 0; + this._drawCallCount = 0; + this.canvasUpdateTimer = null; + console.log("[VideoComparer] Widget constructor called"); + this.name = name; + this.node = node; } - set value(v) { - console.log("[VideoComparer] Widget value setter called with:", v); - - // Reset state + console.log("[VideoComparer] Widget value setter called"); this.isPlaying = false; this.currentFrameIndex = 0; this.stopPlayback(); - - // Process video data (now supporting batch) const videoData = v.video_data || []; - console.log("[VideoComparer] Raw video data:", videoData); - - // Separate videos by type for batch processing const videosA = videoData.filter(video => video.name === "video_a" || video.is_video_a); const videosB = videoData.filter(video => video.name === "video_b" || video.is_video_b); - - // Store all videos for batch processing this.videosA = videosA.map((video, index) => ({ name: `A${index + 1}`, fps: video.fps || 8, frames: video.frames || [], - index: index + index })); - this.videosB = videosB.map((video, index) => ({ name: `B${index + 1}`, fps: video.fps || 8, frames: video.frames || [], - index: index + index })); - - // Calculate max pairs for comparison this.maxPairs = Math.max(this.videosA.length, this.videosB.length); this.currentPairIndex = 0; - - // Calculate batch pagination this.maxBatchPages = Math.ceil(this.maxPairs / this.pairsPerPage); this.currentBatchPage = 0; - - // Maintain backward compatibility - set current video pair - this.videoA = this.videosA[0] || null; - this.videoB = this.videosB[0] || null; - this.framesA = this.videoA ? this.videoA.frames : []; - this.framesB = this.videoB ? this.videoB.frames : []; - this.fps = (this.videoA && this.videoA.fps) || (this.videoB && this.videoB.fps) || 8; - - console.log("[VideoComparer] Setup complete - A:", this.framesA.length, "B:", this.framesB.length, "Pairs:", this.maxPairs); - - // Clear caches + const videoA = this.videosA[0] || null; + const videoB = this.videosB[0] || null; + this.framesA = videoA ? videoA.frames : []; + this.framesB = videoB ? videoB.frames : []; + this.fps = (videoA?.fps) || (videoB?.fps) || 8; this.loadedFramesA = {}; this.loadedFramesB = {}; - - // Set the raw value this._value = v; - - // Update selected pair for batch modes - this.updateSelectedPair(); - - // Preload frames if (this.framesA.length > 0 || this.framesB.length > 0) { this.preloadInitialFrames(); } - - // Update controls and redraw - if (this.node && this.node.updateControlsVisibility) { + if (this.node?.updateControlsVisibility) { this.node.updateControlsVisibility(); } this.node.setDirtyCanvas(true, false); } - get value() { return this._value || { video_data: [] }; } - preloadInitialFrames() { - console.log("[VideoComparer] Preloading initial frames with smart buffering"); - - // Set initial loading state to prevent flicker this.isInitialLoading = true; this.loadedFrameCount = 0; - - // Calculate how many frames to preload for smooth playback - const bufferSize = Math.min(5, Math.max(this.framesA.length, this.framesB.length)); // Preload up to 5 frames + const bufferSize = Math.min(5, Math.max(this.framesA.length, this.framesB.length)); this.targetLoadCount = Math.min(bufferSize, this.framesA.length) + Math.min(bufferSize, this.framesB.length); - - // Clear any existing timer - if (this.initialLoadTimer) { + if (this.initialLoadTimer) clearTimeout(this.initialLoadTimer); - } - - // Load the first several frames immediately without delays - const framesToPreload = Math.min(bufferSize, Math.max(this.framesA.length, this.framesB.length)); - - for (let i = 0; i < framesToPreload; i++) { - if (this.framesA.length > i) { - // Load immediately without setTimeout + for (let i = 0; i < bufferSize; i++) { + if (this.framesA.length > i) this.loadFrame(this.framesA[i], "A"); - } - if (this.framesB.length > i) { - // Load immediately without setTimeout + if (this.framesB.length > i) this.loadFrame(this.framesB[i], "B"); - } } - - // Set a more aggressive timer to end initial loading state this.initialLoadTimer = setTimeout(() => { this.isInitialLoading = false; - console.log("[VideoComparer] Initial loading period ended"); if (this.pendingCanvasUpdate) { this.node.setDirtyCanvas(true, false); this.pendingCanvasUpdate = false; } - }, 1000); // Reduced from 2000ms to 1000ms + }, 1000); } - - loadFrame(frameData, videoId, retryCount = 0) { - if (!frameData) { - console.error(`[VideoComparer] Invalid frame data for video ${videoId}:`, frameData); + loadFrame(frameData, videoId) { + if (!frameData) return null; - } - const cacheKey = `${videoId}_${frameData.frame_index}`; const cache = videoId === "A" ? this.loadedFramesA : this.loadedFramesB; - - // Return from cache if already loaded - if (cache[cacheKey] && cache[cacheKey].complete && !cache[cacheKey].failed) { + if (cache[cacheKey]?.complete && !cache[cacheKey].failed) return cache[cacheKey]; - } - - // If already in queue or loading, return existing image object - if (cache[cacheKey] && (cache[cacheKey].loading || cache[cacheKey].queued)) { + if (cache[cacheKey]?.isLoading || cache[cacheKey]?.queued) return cache[cacheKey]; - } - - // Clean up cache if getting too large - if (Object.keys(cache).length > 10) { - this.cleanupFrameCache(cache, videoId); - } - - // Add to loading queue if not already there - const queueItem = { frameData, videoId, cacheKey, retryCount }; + if (Object.keys(cache).length > 10) + this.cleanupFrameCache(cache); if (!this.loadingQueue.find(item => item.cacheKey === cacheKey)) { - this.loadingQueue.push(queueItem); + this.loadingQueue.push({ frameData, videoId, cacheKey, retryCount: 0 }); } - - // Create placeholder image if (!cache[cacheKey]) { const img = new Image(); img.queued = true; cache[cacheKey] = img; } - - // Process queue this.processLoadingQueue(); - return cache[cacheKey]; } - processLoadingQueue() { - if (this.loadingInProgress) return; + if (this.loadingInProgress) + return; this.loadingInProgress = true; - - // Process queue items up to concurrency limit while (this.loadingQueue.length > 0 && this.activeLoads.size < this.maxConcurrentLoads) { const item = this.loadingQueue.shift(); - this.loadFrameImmediate(item); + if (item) + this.loadFrameImmediate(item); } - this.loadingInProgress = false; - - // Schedule next processing if queue not empty if (this.loadingQueue.length > 0) { setTimeout(() => this.processLoadingQueue(), 100); } } - loadFrameImmediate(queueItem) { const { frameData, videoId, cacheKey, retryCount } = queueItem; - const cache = videoId === "A" ? this.loadedFramesA : this.loadedFramesB; - + const cache = videoId.startsWith("A") ? this.loadedFramesA : this.loadedFramesB; this.activeLoads.add(cacheKey); - - try { - const img = cache[cacheKey] || new Image(); - img.loading = true; - img.queued = false; - img.failed = false; - - img.onload = () => { - console.log(`[VideoComparer] Frame loaded: ${videoId}_${frameData.frame_index}`); - img.loading = false; - this.activeLoads.delete(cacheKey); - - // Track initial loading progress - if (this.isInitialLoading) { - this.loadedFrameCount++; - console.log(`[VideoComparer] Initial loading progress: ${this.loadedFrameCount}/${this.targetLoadCount}`); - - // Only update canvas after sufficient frames are loaded or initial loading is complete - if (this.loadedFrameCount >= this.targetLoadCount || this.loadedFrameCount >= 2) { - this.isInitialLoading = false; - if (this.initialLoadTimer) { - clearTimeout(this.initialLoadTimer); - this.initialLoadTimer = null; - } - this.node.setDirtyCanvas(true, false); - console.log("[VideoComparer] Initial frames loaded, updating canvas"); - } else { - // Store that we have a pending update - this.pendingCanvasUpdate = true; - } - } else { - // Normal loading - debounce canvas updates - this.debouncedCanvasUpdate(); - } - - // Process next item in queue - setTimeout(() => this.processLoadingQueue(), 10); - }; - - img.onerror = (error) => { - console.error(`[VideoComparer] Frame failed to load: ${videoId}_${frameData.frame_index}`, error); - img.loading = false; - img.failed = true; - this.activeLoads.delete(cacheKey); - - // Retry if we haven't exceeded retry attempts - if (retryCount < this.retryAttempts) { - console.log(`[VideoComparer] Retrying frame load: ${videoId}_${frameData.frame_index} (attempt ${retryCount + 1})`); - setTimeout(() => { - const retryItem = { ...queueItem, retryCount: retryCount + 1 }; - this.loadingQueue.unshift(retryItem); // Add to front of queue - this.processLoadingQueue(); - }, 1000 * (retryCount + 1)); // Exponential backoff - } else { - console.error(`[VideoComparer] Failed to load frame after ${this.retryAttempts} attempts: ${videoId}_${frameData.frame_index}`); - delete cache[cacheKey]; - } - - // Process next item in queue - setTimeout(() => this.processLoadingQueue(), 10); - }; - - // Set src with small delay to prevent browser overload - setTimeout(() => { - if (!img.failed) { - img.src = frameData.data_url; - } - }, 50); - - cache[cacheKey] = img; - - } catch (error) { - console.error(`[VideoComparer] Error creating image for frame: ${videoId}_${frameData.frame_index}`, error); + const img = (cache[cacheKey] || new Image()); + img.isLoading = true; + img.queued = false; + img.failed = false; + img.onload = () => { + img.isLoading = false; this.activeLoads.delete(cacheKey); + if (this.isInitialLoading) { + this.loadedFrameCount++; + if (this.loadedFrameCount >= this.targetLoadCount || this.loadedFrameCount >= 2) { + this.isInitialLoading = false; + if (this.initialLoadTimer) + clearTimeout(this.initialLoadTimer); + this.node.setDirtyCanvas(true, false); + } + else { + this.pendingCanvasUpdate = true; + } + } + else { + this.debouncedCanvasUpdate(); + } setTimeout(() => this.processLoadingQueue(), 10); - return null; - } + }; + img.onerror = () => { + img.isLoading = false; + img.failed = true; + this.activeLoads.delete(cacheKey); + if (retryCount < this.retryAttempts) { + setTimeout(() => { + this.loadingQueue.unshift({ ...queueItem, retryCount: retryCount + 1 }); + this.processLoadingQueue(); + }, 1000 * (retryCount + 1)); + } + else { + delete cache[cacheKey]; + } + setTimeout(() => this.processLoadingQueue(), 10); + }; + setTimeout(() => { + if (!img.failed) { + img.src = frameData.data_url || imageDataToUrl(frameData); + } + }, 50); + cache[cacheKey] = img; + } + cleanupFrameCache(cache) { + const keys = Object.keys(cache); + const toRemove = keys.slice(0, Math.floor(keys.length / 2)); + toRemove.forEach(key => { + if (cache[key] && !cache[key].isLoading) + delete cache[key]; + }); + } + debouncedCanvasUpdate() { + if (this.canvasUpdateTimer) + clearTimeout(this.canvasUpdateTimer); + this.canvasUpdateTimer = setTimeout(() => { + this.node.setDirtyCanvas(true, false); + }, 16); } - getFrameImageForIndex(index, videoId) { const frames = videoId === "A" ? this.framesA : this.framesB; - - if (!frames.length || index < 0 || index >= frames.length) { + if (!frames.length || index < 0 || index >= frames.length) return null; - } - return this.loadFrame(frames[index], videoId); } - draw(ctx, node, width, y, height) { - // Dramatically reduce logging frequency - if (!this._drawCallCount) this._drawCallCount = 0; this._drawCallCount++; - - // Only log every 2000th draw call - if (this._drawCallCount % 2000 === 0) { - console.log("[VideoComparer] Draw called - mode:", node.properties?.comparer_mode, this._drawCallCount); - } - this.y = y; this.last_y = y; - - // Calculate the actual available height for videos - const [nodeWidth, nodeHeight] = node.size; + const nodeHeight = node.size[1]; const availableHeight = nodeHeight - y - 10; - - // Get current comparison mode const mode = node.properties?.comparer_mode || "Playback"; - - // Draw videos based on mode switch (mode) { case "Side-by-Side": this.drawSideBySideMode(ctx, y, width, availableHeight); @@ -649,91 +400,71 @@ class VideoComparerWidget { case "Batch": this.drawBatchMode(ctx, y, width, availableHeight); break; - default: // "Playback" + default: this.drawPlaybackMode(ctx, y, width, availableHeight); break; } - - // Draw controls based on mode if (["Grid", "Batch"].includes(mode) && this.maxPairs > 1) { this.drawBatchControls(ctx, y, width, availableHeight); - } else { - // Draw regular playback controls - this.drawPlaybackControls(ctx, y + availableHeight - 45, width); + } + else { + this.drawPlaybackControls(ctx, y + availableHeight - 45, width); } } - drawPlaybackMode(ctx, y, width, availableHeight) { - // For single video playback, show only the selected video (A by default) const videoId = this.node.properties?.selected_video || "A"; const videoFrames = videoId === "A" ? this.framesA : this.framesB; - if (!videoFrames.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight - 45, videoId); return; } - - // Get current frame to display const currentFrame = Math.min(this.currentFrameIndex, videoFrames.length - 1); const frameImg = this.getFrameImageForIndex(currentFrame, videoId); - - // Show loading only if we're in initial loading AND the current frame isn't ready - // This prevents flickering during normal playback if (this.isInitialLoading && (!frameImg || !frameImg.complete)) { this.drawLoadingMessage(ctx, y, width, availableHeight - 45); - } else if (frameImg && frameImg.complete && !frameImg.failed) { - // Draw the frame - subtract 45 for controls area + } + else if (frameImg?.complete && !frameImg.failed) { this.drawFrame(ctx, frameImg, y, width, availableHeight - 45); - - // Draw frame counter this.drawFrameCounter(ctx, y + 10, width, currentFrame + 1, videoFrames.length, videoId); - } else { - // Frame not ready but not in initial loading - show previous frame or placeholder + } + else { this.drawLoadingMessage(ctx, y, width, availableHeight - 45); } } - drawSideBySideMode(ctx, y, width, availableHeight) { const halfWidth = width / 2; - const hasVideoA = this.framesA.length > 0; - const hasVideoB = this.framesB.length > 0; - - if (!hasVideoA && !hasVideoB) { + if (!this.framesA.length && !this.framesB.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, "both"); return; } - - // Calculate current frames const currentFrameA = Math.min(this.currentFrameIndex, this.framesA.length - 1); const currentFrameB = Math.min(this.currentFrameIndex, this.framesB.length - 1); - - // Draw video A on the left - if (hasVideoA) { + if (this.framesA.length) { const frameImgA = this.getFrameImageForIndex(currentFrameA, "A"); - if (!this.isInitialLoading && frameImgA && frameImgA.complete) { + if (!this.isInitialLoading && frameImgA?.complete) { this.drawFrameInRegion(ctx, frameImgA, y, 0, halfWidth, availableHeight); this.drawFrameCounter(ctx, y + 10, halfWidth, currentFrameA + 1, this.framesA.length, "A"); - } else { + } + else { this.drawLoadingMessage(ctx, y, halfWidth, availableHeight, 0); } - } else { + } + else { this.drawNoVideoMessage(ctx, y, halfWidth, availableHeight, "A", 0); } - - // Draw video B on the right - if (hasVideoB) { + if (this.framesB.length) { const frameImgB = this.getFrameImageForIndex(currentFrameB, "B"); - if (!this.isInitialLoading && frameImgB && frameImgB.complete) { + if (!this.isInitialLoading && frameImgB?.complete) { this.drawFrameInRegion(ctx, frameImgB, y, halfWidth, halfWidth, availableHeight); this.drawFrameCounter(ctx, y + 10, halfWidth, currentFrameB + 1, this.framesB.length, "B", halfWidth); - } else { + } + else { this.drawLoadingMessage(ctx, y, halfWidth, availableHeight, halfWidth); } - } else { + } + else { this.drawNoVideoMessage(ctx, y, halfWidth, availableHeight, "B", halfWidth); } - - // Draw separator line ctx.beginPath(); ctx.moveTo(halfWidth, y); ctx.lineTo(halfWidth, y + availableHeight - 45); @@ -741,48 +472,40 @@ class VideoComparerWidget { ctx.lineWidth = 1; ctx.stroke(); } - drawStackedMode(ctx, y, width, availableHeight) { const halfHeight = (availableHeight - 45) / 2; - const hasVideoA = this.framesA.length > 0; - const hasVideoB = this.framesB.length > 0; - - if (!hasVideoA && !hasVideoB) { + if (!this.framesA.length && !this.framesB.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, "both"); return; } - - // Calculate current frames const currentFrameA = Math.min(this.currentFrameIndex, this.framesA.length - 1); const currentFrameB = Math.min(this.currentFrameIndex, this.framesB.length - 1); - - // Draw video A on top - if (hasVideoA) { + if (this.framesA.length) { const frameImgA = this.getFrameImageForIndex(currentFrameA, "A"); - if (frameImgA && frameImgA.complete) { + if (frameImgA?.complete) { this.drawFrameInRegion(ctx, frameImgA, y, 0, width, halfHeight); this.drawFrameCounter(ctx, y + 10, width, currentFrameA + 1, this.framesA.length, "A"); - } else { + } + else { this.drawLoadingMessage(ctx, y, width, halfHeight); } - } else { + } + else { this.drawNoVideoMessage(ctx, y, width, halfHeight, "A"); } - - // Draw video B on bottom - if (hasVideoB) { + if (this.framesB.length) { const frameImgB = this.getFrameImageForIndex(currentFrameB, "B"); - if (frameImgB && frameImgB.complete) { + if (frameImgB?.complete) { this.drawFrameInRegion(ctx, frameImgB, y + halfHeight, 0, width, halfHeight); this.drawFrameCounter(ctx, y + halfHeight + 10, width, currentFrameB + 1, this.framesB.length, "B"); - } else { + } + else { this.drawLoadingMessage(ctx, y + halfHeight, width, halfHeight); } - } else { + } + else { this.drawNoVideoMessage(ctx, y + halfHeight, width, halfHeight, "B"); } - - // Draw separator line ctx.beginPath(); ctx.moveTo(0, y + halfHeight); ctx.lineTo(width, y + halfHeight); @@ -790,55 +513,39 @@ class VideoComparerWidget { ctx.lineWidth = 1; ctx.stroke(); } - drawSliderMode(ctx, y, width, availableHeight) { - const hasVideoA = this.framesA.length > 0; - const hasVideoB = this.framesB.length > 0; - - if (!hasVideoA && !hasVideoB) { + if (!this.framesA.length && !this.framesB.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, "both"); return; } - - // Calculate current frames const currentFrameA = Math.min(this.currentFrameIndex, this.framesA.length - 1); const currentFrameB = Math.min(this.currentFrameIndex, this.framesB.length - 1); - - // Draw video A as the base - if (hasVideoA) { + if (this.framesA.length) { const frameImgA = this.getFrameImageForIndex(currentFrameA, "A"); - if (frameImgA && frameImgA.complete) { + if (frameImgA?.complete) { this.drawFrame(ctx, frameImgA, y, width, availableHeight - 45); this.drawFrameCounter(ctx, y + 10, width, currentFrameA + 1, this.framesA.length, "A"); - } else { + } + else { this.drawLoadingMessage(ctx, y, width, availableHeight - 45); } } - - // Draw video B with slider if mouse is over - if (hasVideoB && this.node.isPointerOver) { + if (this.framesB.length && this.node.isPointerOver) { const frameImgB = this.getFrameImageForIndex(currentFrameB, "B"); - if (frameImgB && frameImgB.complete) { - // Get slider position from mouse - const sliderX = this.node.pointerOverPos[0]; - - // Draw B with clipping + if (frameImgB?.complete) { + const sliderX = this.node.pointerOverPos?.[0] || width / 2; ctx.save(); ctx.beginPath(); ctx.rect(0, y, sliderX, availableHeight - 45); ctx.clip(); this.drawFrame(ctx, frameImgB, y, width, availableHeight - 45); ctx.restore(); - - // Draw slider line ctx.beginPath(); ctx.moveTo(sliderX, y); ctx.lineTo(sliderX, y + availableHeight - 45); ctx.strokeStyle = "rgba(255,255,255,0.8)"; ctx.lineWidth = 2; ctx.stroke(); - - // Draw B label near slider ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(sliderX + 5, y + 10, 20, 20); ctx.fillStyle = "white"; @@ -848,44 +555,31 @@ class VideoComparerWidget { } } } - drawOnionSkinMode(ctx, y, width, availableHeight) { - const hasVideoA = this.framesA.length > 0; - const hasVideoB = this.framesB.length > 0; - - if (!hasVideoA && !hasVideoB) { + if (!this.framesA.length && !this.framesB.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, "both"); return; } - - // Calculate current frames const currentFrameA = Math.min(this.currentFrameIndex, this.framesA.length - 1); const currentFrameB = Math.min(this.currentFrameIndex, this.framesB.length - 1); - - // Get opacity from node properties const opacity = this.node.properties?.onionSkinOpacity || 0.5; - - // Draw video A as the base - if (hasVideoA) { + if (this.framesA.length) { const frameImgA = this.getFrameImageForIndex(currentFrameA, "A"); - if (frameImgA && frameImgA.complete) { + if (frameImgA?.complete) { this.drawFrame(ctx, frameImgA, y, width, availableHeight - 45); this.drawFrameCounter(ctx, y + 10, width, currentFrameA + 1, this.framesA.length, "A"); - } else { + } + else { this.drawLoadingMessage(ctx, y, width, availableHeight - 45); } } - - // Draw video B with opacity - if (hasVideoB) { + if (this.framesB.length) { const frameImgB = this.getFrameImageForIndex(currentFrameB, "B"); - if (frameImgB && frameImgB.complete) { + if (frameImgB?.complete) { ctx.save(); ctx.globalAlpha = opacity; this.drawFrame(ctx, frameImgB, y, width, availableHeight - 45); ctx.restore(); - - // Draw B label ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(width - 30, y + 10, 20, 20); ctx.fillStyle = "white"; @@ -895,89 +589,104 @@ class VideoComparerWidget { } } } - drawSyncCompareMode(ctx, y, width, availableHeight) { - // This mode shows a single view that toggles between video A and B on click const selectedVideo = this.node.selectedVideo || "A"; const frames = selectedVideo === "A" ? this.framesA : this.framesB; - if (!frames.length) { this.drawNoVideoMessage(ctx, y, width, availableHeight, selectedVideo); return; } - - // Calculate current frame const currentFrame = Math.min(this.currentFrameIndex, frames.length - 1); const frameImg = this.getFrameImageForIndex(currentFrame, selectedVideo); - - if (frameImg && frameImg.complete) { - // Draw the frame + if (frameImg?.complete) { this.drawFrame(ctx, frameImg, y, width, availableHeight - 45); - - // Draw frame counter and video indicator this.drawFrameCounter(ctx, y + 10, width, currentFrame + 1, frames.length, selectedVideo); - - // Draw comparison toggle hint - positioned just above the timeline (45px controls + 5px margin) ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(width / 2 - 60, y + availableHeight - 65, 120, 24); ctx.fillStyle = "white"; ctx.font = "12px Arial"; ctx.textAlign = "center"; ctx.fillText("Click to toggle A/B", width / 2, y + availableHeight - 49); - } else { - // Draw loading message + } + else { this.drawLoadingMessage(ctx, y, width, availableHeight - 45); } } - + drawGridMode(ctx, y, width, availableHeight) { + // Simplified grid - draw first 4 pairs in 2x2 grid + const cols = 2, rows = 2; + const cellWidth = width / cols, cellHeight = (availableHeight - 30) / rows; + for (let i = 0; i < Math.min(this.maxPairs, 4); i++) { + const col = i % cols, row = Math.floor(i / cols); + const x = col * cellWidth, cellY = y + row * cellHeight; + const videoA = this.videosA[i], videoB = this.videosB[i]; + if (videoA?.frames.length) { + const frame = this.loadFrame(videoA.frames[this.currentFrameIndex % videoA.frames.length], `A_${i}`); + if (frame?.complete) + this.drawFrameInRegion(ctx, frame, cellY, x, cellWidth / 2, cellHeight); + } + if (videoB?.frames.length) { + const frame = this.loadFrame(videoB.frames[this.currentFrameIndex % videoB.frames.length], `B_${i}`); + if (frame?.complete) + this.drawFrameInRegion(ctx, frame, cellY, x + cellWidth / 2, cellWidth / 2, cellHeight); + } + } + } + drawBatchMode(ctx, y, width, availableHeight) { + const startPair = this.currentBatchPage * this.pairsPerPage; + const pairHeight = (availableHeight - 30) / this.pairsPerPage; + for (let i = 0; i < this.pairsPerPage && startPair + i < this.maxPairs; i++) { + const pairIdx = startPair + i; + const pairY = y + i * pairHeight; + const videoA = this.videosA[pairIdx], videoB = this.videosB[pairIdx]; + if (videoA?.frames.length) { + const frame = this.loadFrame(videoA.frames[this.currentFrameIndex % videoA.frames.length], `A_${pairIdx}`); + if (frame?.complete) + this.drawFrameInRegion(ctx, frame, pairY, 0, width / 2, pairHeight); + } + if (videoB?.frames.length) { + const frame = this.loadFrame(videoB.frames[this.currentFrameIndex % videoB.frames.length], `B_${pairIdx}`); + if (frame?.complete) + this.drawFrameInRegion(ctx, frame, pairY, width / 2, width / 2, pairHeight); + } + } + } drawFrame(ctx, img, y, width, availableHeight) { - if (!img || !img.complete) return; - + if (!img?.complete) + return; const imageAspect = img.naturalWidth / img.naturalHeight; const canvasAspect = width / availableHeight; - let drawWidth, drawHeight, offsetX = 0, offsetY = 0; - if (imageAspect > canvasAspect) { - // Image is wider relative to container drawWidth = width; drawHeight = width / imageAspect; offsetY = (availableHeight - drawHeight) / 2; - } else { - // Image is taller relative to container + } + else { drawHeight = availableHeight; drawWidth = availableHeight * imageAspect; offsetX = (width - drawWidth) / 2; } - - // Draw image centered ctx.drawImage(img, offsetX, y + offsetY, drawWidth, drawHeight); } - drawFrameInRegion(ctx, img, y, x, regionWidth, regionHeight) { - if (!img || !img.complete) return; - + if (!img?.complete) + return; const imageAspect = img.naturalWidth / img.naturalHeight; const regionAspect = regionWidth / regionHeight; - let drawWidth, drawHeight, offsetX = 0, offsetY = 0; - if (imageAspect > regionAspect) { - // Image is wider relative to region drawWidth = regionWidth; drawHeight = regionWidth / imageAspect; offsetY = (regionHeight - drawHeight) / 2; - } else { - // Image is taller relative to region + } + else { drawHeight = regionHeight; drawWidth = regionHeight * imageAspect; offsetX = (regionWidth - drawWidth) / 2; } - - // Draw image centered within region ctx.drawImage(img, x + offsetX, y + offsetY, drawWidth, drawHeight); } - drawFrameCounter(ctx, y, width, current, total, videoId, offsetX = 0) { ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(offsetX + 10, y, 80, 24); @@ -986,1333 +695,310 @@ class VideoComparerWidget { ctx.textAlign = "left"; ctx.fillText(`${videoId}: ${current}/${total}`, offsetX + 15, y + 16); } - drawNoVideoMessage(ctx, y, width, height, videoId, offsetX = 0) { ctx.fillStyle = "rgba(0,0,0,0.3)"; ctx.fillRect(offsetX, y, width, height); - ctx.fillStyle = "white"; ctx.font = "14px Arial"; ctx.textAlign = "center"; - - if (videoId === "both") { - ctx.fillText("No videos available", offsetX + width/2, y + height/2); - } else { - ctx.fillText(`No video ${videoId} available`, offsetX + width/2, y + height/2); - } + ctx.fillText(videoId === "both" ? "No videos available" : `No video ${videoId} available`, offsetX + width / 2, y + height / 2); } - drawLoadingMessage(ctx, y, width, height, offsetX = 0) { ctx.fillStyle = "rgba(0,0,0,0.3)"; ctx.fillRect(offsetX, y, width, height); - ctx.fillStyle = "white"; ctx.font = "14px Arial"; ctx.textAlign = "center"; - ctx.fillText("Loading frames...", offsetX + width/2, y + height/2); + ctx.fillText("Loading frames...", offsetX + width / 2, y + height / 2); } - drawPlaybackControls(ctx, y, width) { - const controlHeight = 45; // Increased from 30 to 45 for larger controls - - // Draw control background + const controlHeight = 45; ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0, y, width, controlHeight); - - // Calculate button dimensions - increased sizes - const buttonSize = 36; // Increased from 24 to 36 - const buttonPadding = 8; // Increased from 5 to 8 - const playPauseX = 12; // Increased from 10 to 12 - const sliderStart = playPauseX + buttonSize + buttonPadding; - const sliderWidth = width - sliderStart - buttonPadding - 60; // Increased space for frame counter - - // Draw play/pause button with rounded corners for better appearance + const buttonSize = 36; + const playPauseX = 12; + const sliderStart = playPauseX + buttonSize + 8; + const sliderWidth = width - sliderStart - 68; ctx.fillStyle = "rgba(100,100,100,0.8)"; const buttonY = y + (controlHeight - buttonSize) / 2; ctx.beginPath(); ctx.roundRect(playPauseX, buttonY, buttonSize, buttonSize, 4); ctx.fill(); - - // Draw play/pause icon - larger and better positioned ctx.fillStyle = "white"; - ctx.font = "20px Arial"; // Increased from 14px to 20px + ctx.font = "20px Arial"; ctx.textAlign = "center"; ctx.fillText(this.isPlaying ? "⏸" : "▶", playPauseX + buttonSize / 2, y + controlHeight / 2 + 7); - - // Draw scrubber track - taller and more visible - const trackHeight = 8; // Increased from 4 to 8 + const trackHeight = 8; ctx.fillStyle = "rgba(60,60,60,0.8)"; ctx.beginPath(); ctx.roundRect(sliderStart, y + controlHeight / 2 - trackHeight / 2, sliderWidth, trackHeight, trackHeight / 2); ctx.fill(); - - // Calculate scrubber position const totalFrames = Math.max(this.framesA.length, this.framesB.length); if (totalFrames > 0) { const progress = this.currentFrameIndex / (totalFrames - 1); const scrubberPos = sliderStart + progress * sliderWidth; - - // Draw scrubber handle - larger and more grabbable - const scrubberRadius = 10; // Increased from 6 to 10 - - // Draw scrubber shadow for depth + const scrubberRadius = 10; ctx.fillStyle = "rgba(0,0,0,0.3)"; ctx.beginPath(); ctx.arc(scrubberPos + 1, y + controlHeight / 2 + 1, scrubberRadius, 0, Math.PI * 2); ctx.fill(); - - // Draw main scrubber handle ctx.fillStyle = "white"; ctx.beginPath(); ctx.arc(scrubberPos, y + controlHeight / 2, scrubberRadius, 0, Math.PI * 2); ctx.fill(); - - // Draw inner highlight for better visual feedback - ctx.fillStyle = "rgba(255,255,255,0.8)"; - ctx.beginPath(); - ctx.arc(scrubberPos - 2, y + controlHeight / 2 - 2, scrubberRadius * 0.3, 0, Math.PI * 2); - ctx.fill(); - - // Draw frame counter - larger font ctx.fillStyle = "white"; - ctx.font = "14px Arial"; // Increased from 12px to 14px + ctx.font = "14px Arial"; ctx.textAlign = "right"; ctx.fillText(`${this.currentFrameIndex + 1}/${totalFrames}`, width - 12, y + controlHeight / 2 + 5); } } - + drawBatchControls(ctx, y, width, availableHeight) { + const controlY = y + availableHeight - 30; + ctx.save(); + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(0, controlY, width, 30); + ctx.fillStyle = "white"; + ctx.font = "12px Arial"; + ctx.textAlign = "left"; + ctx.fillText(`Frame: ${this.currentFrameIndex + 1}`, 10, controlY + 18); + const buttonWidth = 60, buttonHeight = 20, buttonY = controlY + 5; + ctx.fillStyle = this.isPlaying ? "rgba(200,100,100,0.8)" : "rgba(100,200,100,0.8)"; + ctx.fillRect(80, buttonY, buttonWidth, buttonHeight); + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.fillText(this.isPlaying ? "⏸ Pause" : "▶ Play", 80 + buttonWidth / 2, buttonY + 14); + ctx.restore(); + } startPlayback() { - if (this.isPlaying) return; - - // Check if we have enough frames loaded for smooth playback - const minFramesNeeded = Math.min(3, Math.max(this.framesA.length, this.framesB.length)); - const hasMinFramesA = this.framesA.length === 0 || this.getLoadedFrameCount("A") >= minFramesNeeded; - const hasMinFramesB = this.framesB.length === 0 || this.getLoadedFrameCount("B") >= minFramesNeeded; - - if (!hasMinFramesA || !hasMinFramesB) { - console.log("[VideoComparer] Not enough frames loaded, preloading before playback"); - // Force preload more frames before starting - this.preloadForPlayback(() => { - this.startPlaybackImmediate(); - }); + if (this.isPlaying) return; - } - - this.startPlaybackImmediate(); - } - - getLoadedFrameCount(videoId) { - const cache = videoId === "A" ? this.loadedFramesA : this.loadedFramesB; - return Object.values(cache).filter(img => img && img.complete && !img.failed).length; - } - - preloadForPlayback(callback) { - const framesToLoad = Math.min(5, Math.max(this.framesA.length, this.framesB.length)); - let loadedCount = 0; - let targetCount = 0; - - // Count how many frames we need to load - for (let i = 0; i < framesToLoad; i++) { - if (this.framesA.length > i) targetCount++; - if (this.framesB.length > i) targetCount++; - } - - const onFrameLoaded = () => { - loadedCount++; - if (loadedCount >= targetCount) { - console.log("[VideoComparer] Preload for playback complete"); - callback(); - } - }; - - // Load frames with immediate feedback - for (let i = 0; i < framesToLoad; i++) { - if (this.framesA.length > i) { - const img = this.loadFrame(this.framesA[i], "A"); - if (img && img.complete) { - onFrameLoaded(); - } else if (img) { - const originalOnload = img.onload; - img.onload = () => { - if (originalOnload) originalOnload(); - onFrameLoaded(); - }; - } - } - if (this.framesB.length > i) { - const img = this.loadFrame(this.framesB[i], "B"); - if (img && img.complete) { - onFrameLoaded(); - } else if (img) { - const originalOnload = img.onload; - img.onload = () => { - if (originalOnload) originalOnload(); - onFrameLoaded(); - }; - } - } - } - - // Fallback timeout in case some frames fail to load - setTimeout(() => { - if (loadedCount < targetCount) { - console.log("[VideoComparer] Preload timeout, starting playback anyway"); - callback(); - } - }, 2000); - } - - startPlaybackImmediate() { - console.log("[VideoComparer] Starting playback at FPS:", this.fps); this.isPlaying = true; - this.lastFrameTime = performance.now(); - - // Ensure we're not in initial loading state this.isInitialLoading = false; - + this.lastFrameTime = performance.now(); const playbackLoop = () => { const now = performance.now(); - const frameDelay = 1000 / this.fps; - - if (now - this.lastFrameTime >= frameDelay) { + if (now - this.lastFrameTime >= 1000 / this.fps) { this.advanceFrame(); this.lastFrameTime = now; } - - if (this.isPlaying) { + if (this.isPlaying) this.animationFrame = requestAnimationFrame(playbackLoop); - } }; - this.animationFrame = requestAnimationFrame(playbackLoop); this.node.setDirtyCanvas(true, false); } - stopPlayback() { - if (!this.isPlaying) return; - - console.log("[VideoComparer] Stopping playback"); + if (!this.isPlaying) + return; this.isPlaying = false; - if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } - this.node.setDirtyCanvas(true, false); } - togglePlayback() { - if (this.isPlaying) { - this.stopPlayback(); - } else { - this.startPlayback(); - } + this.isPlaying ? this.stopPlayback() : this.startPlayback(); } - advanceFrame() { const totalFrames = Math.max(this.framesA.length, this.framesB.length); - if (totalFrames <= 1) return; - - this.currentFrameIndex++; - - // Loop back to beginning if reached the end - if (this.currentFrameIndex >= totalFrames) { - this.currentFrameIndex = 0; - } - - // Preload next frames - this.preloadNextFrames(); - - // Update display + if (totalFrames === 0) + return; + this.currentFrameIndex = (this.currentFrameIndex + 1) % totalFrames; this.node.setDirtyCanvas(true, false); } - previousFrame() { const totalFrames = Math.max(this.framesA.length, this.framesB.length); - if (totalFrames === 0) return; // No frames to navigate - // If only one frame, or currently at the first frame of multiple, and try to go previous, loop to end. - - this.currentFrameIndex--; - if (this.currentFrameIndex < 0) { - this.currentFrameIndex = totalFrames > 0 ? totalFrames - 1 : 0; // Loop to the last frame or 0 if no frames - } - - this.preloadNextFrames(); + if (totalFrames === 0) + return; + this.currentFrameIndex = (this.currentFrameIndex - 1 + totalFrames) % totalFrames; this.node.setDirtyCanvas(true, false); } - - nextFrame() { - const totalFrames = Math.max(this.framesA.length, this.framesB.length); - if (totalFrames === 0) return; // No frames to navigate - // If only one frame, or currently at the last frame of multiple, and try to go next, loop to start. - - this.currentFrameIndex++; - if (this.currentFrameIndex >= totalFrames) { - this.currentFrameIndex = 0; // Loop to the first frame - } - - this.preloadNextFrames(); - this.node.setDirtyCanvas(true, false); - } - - preloadNextFrames() { - const totalFrames = Math.max(this.framesA.length, this.framesB.length); - if (totalFrames <= 1) return; - - // Preload more frames ahead for smoother playback - const framesToPreload = Math.min(3, totalFrames); // Preload 3 frames ahead instead of 1 - - for (let i = 1; i <= framesToPreload; i++) { - const nextIndex = (this.currentFrameIndex + i) % totalFrames; - - // Load without delays during playback for smoothness - if (this.framesA.length > nextIndex) { - this.loadFrame(this.framesA[nextIndex], "A"); - } - if (this.framesB.length > nextIndex) { - this.loadFrame(this.framesB[nextIndex], "B"); - } - } - - // Also preload 1 frame behind - const prevIndex = (this.currentFrameIndex - 1 + totalFrames) % totalFrames; - if (this.framesA.length > prevIndex) { - this.loadFrame(this.framesA[prevIndex], "A"); - } - if (this.framesB.length > prevIndex) { - this.loadFrame(this.framesB[prevIndex], "B"); - } - } - - seekToFrame(frameIndex) { - const totalFrames = Math.max(this.framesA.length, this.framesB.length); - if (totalFrames <= 1) return; - - // Clamp index to valid range - const newIndex = Math.max(0, Math.min(frameIndex, totalFrames - 1)); - - if (this.currentFrameIndex !== newIndex) { - this.currentFrameIndex = newIndex; - this.preloadNextFrames(); - this.node.setDirtyCanvas(true, false); - } - } - - seekToPosition(x, width) { - // Calculate button dimensions for scrubber position - updated to match new sizes - const buttonSize = 36; // Updated from 24 to 36 - const buttonPadding = 8; // Updated from 5 to 8 - const playPauseX = 12; // Updated from 10 to 12 - const sliderStart = playPauseX + buttonSize + buttonPadding; - const sliderWidth = width - sliderStart - buttonPadding - 60; - - // Convert x position to progress (0-1) - let progress = (x - sliderStart) / sliderWidth; - progress = Math.max(0, Math.min(1, progress)); // Clamp to 0-1 - - // Convert progress to frame index - const totalFrames = Math.max(this.framesA.length, this.framesB.length); - const frameIndex = Math.floor(progress * (totalFrames - 1)); - - this.seekToFrame(frameIndex); - } - + nextFrame() { this.advanceFrame(); } mouse(event, pos, node) { - // Handle mouse events for the widget - const [x, y] = pos; - const width = node.size[0]; - const height = node.size[1]; - const availableHeight = height - this.y - 10; - const controlsY = this.y + availableHeight - 45; - const mode = node.properties?.comparer_mode || "Playback"; - - // Handle batch mode controls - if (["Grid", "Batch"].includes(mode) && this.maxPairs > 1) { - const batchControlsY = this.y + availableHeight - 30; - - if (y >= batchControlsY && event.type === "pointerdown") { - const buttonHeight = 20; - const buttonY = batchControlsY + 5; - - // Play/pause button - const playButtonX = 80; - const buttonWidth = 60; - if (x >= playButtonX && x <= playButtonX + buttonWidth && - y >= buttonY && y <= buttonY + buttonHeight) { - this.togglePlayback(); - return true; - } - - // Batch navigation buttons (only for batch mode) - if (mode === "Batch") { - // Previous button - if (x >= 150 && x <= 210 && y >= buttonY && y <= buttonY + buttonHeight) { - this.previousBatchPage(); - return true; - } - - // Next button - if (x >= 220 && x <= 280 && y >= buttonY && y <= buttonY + buttonHeight) { - this.nextBatchPage(); - return true; - } - } - - return true; - } - - return false; - } - - // Add robust scrubbing state management - if (this.isScrubbing && event.type === "pointermove") { - if (event.buttons === 0 || event.buttons === undefined) { - console.log("[VideoComparer] Detected mouse move without button press, stopping scrubbing"); - this.isScrubbing = false; - return false; - } - } - - // Handle pointerup and other release events globally - if (event.type === "pointerup" || event.type === "mouseup" || event.type === "pointercancel") { - if (this.isScrubbing) { - console.log("[VideoComparer] Stopping scrubbing on:", event.type); - this.isScrubbing = false; - return true; - } - } - - // Check if click is in controls area (for non-batch modes) - if (y >= controlsY) { - if (event.type === "pointerdown") { - // Check if click is on play/pause button - const buttonSize = 36; - const playPauseX = 12; - if (x >= playPauseX && x <= playPauseX + buttonSize && - y >= controlsY + (45 - buttonSize) / 2 && y <= controlsY + (45 + buttonSize) / 2) { - this.togglePlayback(); - return true; - } - - // Check if click is on scrubber - const buttonPadding = 8; - const sliderStart = playPauseX + buttonSize + buttonPadding; - const sliderWidth = width - sliderStart - buttonPadding - 60; - - if (x >= sliderStart && x <= sliderStart + sliderWidth) { - console.log("[VideoComparer] Starting scrubbing"); - this.isScrubbing = true; - this.seekToPosition(x, width); - - // Set a safety timeout to auto-stop scrubbing - this.scrubTimeout = setTimeout(() => { - if (this.isScrubbing) { - console.log("[VideoComparer] Auto-stopping scrubbing due to timeout"); - this.isScrubbing = false; - } - }, 5000); - - return true; - } - } - } - - // Handle pointermove during scrubbing - if (event.type === "pointermove" && this.isScrubbing) { - if (event.buttons > 0) { - this.seekToPosition(x, width); - return true; - } else { - console.log("[VideoComparer] No button pressed during move, stopping scrubbing"); - this.isScrubbing = false; - if (this.scrubTimeout) { - clearTimeout(this.scrubTimeout); - this.scrubTimeout = null; - } - return false; - } - } - - // Handle sync compare mode click - if (event.type === "pointerdown" && - y >= this.y && y < controlsY && - node.properties?.comparer_mode === "Sync Compare") { - + if (event.type !== "mousedown") + return false; + const mode = node.properties?.comparer_mode; + if (mode === "Sync Compare") { node.selectedVideo = node.selectedVideo === "A" ? "B" : "A"; node.setDirtyCanvas(true, false); return true; } - - // Handle slider mode hover - if (event.type === "pointermove" && - !this.isScrubbing && - y >= this.y && y < controlsY && - node.properties?.comparer_mode === "Slider") { - - node.pointerOverPos = [...pos]; - node.isPointerOver = true; - node.setDirtyCanvas(true, false); - return true; + // Check playback controls + const widgetY = this.y || 0; + const availableHeight = node.size[1] - widgetY - 10; + const controlsY = widgetY + availableHeight - 45; + if (pos[1] >= controlsY && pos[1] <= controlsY + 45) { + if (pos[0] >= 12 && pos[0] <= 48) { + this.togglePlayback(); + return true; + } + const sliderStart = 56, sliderWidth = node.size[0] - sliderStart - 68; + if (pos[0] >= sliderStart && pos[0] <= sliderStart + sliderWidth) { + const totalFrames = Math.max(this.framesA.length, this.framesB.length); + if (totalFrames > 0) { + const progress = (pos[0] - sliderStart) / sliderWidth; + this.currentFrameIndex = Math.floor(progress * (totalFrames - 1)); + node.setDirtyCanvas(true, false); + return true; + } + } } - return false; } - computeSize(width) { - const mode = this.node?.properties?.comparer_mode || "Playback"; - - // Base height calculation - let height = Math.max(300, width * 0.75); - - // Adjust height based on layout mode - switch (mode) { - case "Stacked": - height = Math.max(500, width * 1.2); - break; - case "Side-by-Side": - height = Math.max(300, width * 0.6); - break; - case "Grid": - // Grid mode needs more height to show multiple pairs - const pairs = Math.min(this.maxPairs || 1, 8); - const cols = Math.ceil(Math.sqrt(pairs * 2)); - const rows = Math.ceil((pairs * 2) / cols); - height = Math.max(400, (width / cols) * rows + 60); - break; - case "Batch": - // Batch mode shows multiple pairs vertically - const visiblePairs = Math.min(this.maxPairs || 1, this.pairsPerPage); - height = Math.max(500, visiblePairs * (width * 0.4) + 60); - break; - default: - height = Math.max(300, width * 0.75); - break; - } - - // Add space for controls - height += 55; - - return [width, height]; + return [width, 300]; } - - // Cleanup method onRemoved() { - console.log("[VideoComparer] Cleaning up widget resources"); - - // Clear scrub timeout - if (this.scrubTimeout) { - clearTimeout(this.scrubTimeout); - this.scrubTimeout = null; - } - - // Reset scrubbing state - this.isScrubbing = false; - - // Clean up all cached frames - this.cleanupAllFrames(); - - if (this.playbackInterval) { - clearInterval(this.playbackInterval); - this.playbackInterval = null; - } - } - - cleanupAllFrames() { - // Clear all frame caches - Object.keys(this.loadedFramesA).forEach(key => { - if (this.loadedFramesA[key] && this.loadedFramesA[key].src) { - this.loadedFramesA[key].src = ""; - } - delete this.loadedFramesA[key]; - }); - - Object.keys(this.loadedFramesB).forEach(key => { - if (this.loadedFramesB[key] && this.loadedFramesB[key].src) { - this.loadedFramesB[key].src = ""; - } - delete this.loadedFramesB[key]; - }); - - this.loadedFramesA = {}; - this.loadedFramesB = {}; - } - - // Add memory management methods - cleanupFrameCache(cache, videoId) { - const keys = Object.keys(cache); - if (keys.length <= 10) return; - - console.log(`[VideoComparer] Cleaning up frame cache for video ${videoId}, current size: ${keys.length}`); - - // Sort keys by frame index and keep only recent frames around current position - const currentIndex = this.currentFrameIndex; - const keysToRemove = keys.filter(key => { - const frameIndex = parseInt(key.split('_')[1]); - return Math.abs(frameIndex - currentIndex) > 5; // Keep frames within 5 of current - }); - - keysToRemove.forEach(key => { - if (cache[key] && !cache[key].loading) { - if (cache[key].src) { - cache[key].src = ""; - } - delete cache[key]; - } - }); - - console.log(`[VideoComparer] Cleaned up ${keysToRemove.length} frames from ${videoId} cache`); - } - - cleanupOldestFrames() { - console.log("[VideoComparer] Cleaning up oldest frames due to memory pressure"); - - const cleanupCache = (cache, videoId) => { - const keys = Object.keys(cache); - if (keys.length <= 5) return; // Keep minimum frames - - // Sort by distance from current frame, remove furthest ones - const currentIndex = this.currentFrameIndex; - const sortedKeys = keys.sort((a, b) => { - const indexA = parseInt(a.split('_')[1]); - const indexB = parseInt(b.split('_')[1]); - const distA = Math.abs(indexA - currentIndex); - const distB = Math.abs(indexB - currentIndex); - return distB - distA; // Sort by distance, furthest first - }); - - // Remove half of the furthest frames - const toRemove = sortedKeys.slice(0, Math.floor(keys.length / 2)); - toRemove.forEach(key => { - if (cache[key] && !cache[key].loading) { - if (cache[key].src) { - cache[key].src = ""; - } - delete cache[key]; - } - }); - - console.log(`[VideoComparer] Cleaned ${toRemove.length} old frames from ${videoId} cache`); - }; - - cleanupCache(this.loadedFramesA, "A"); - cleanupCache(this.loadedFramesB, "B"); - } - - // Add debounced canvas update method - debouncedCanvasUpdate() { - // Clear any existing debounce timer - if (this.canvasUpdateTimer) { + this.stopPlayback(); + if (this.initialLoadTimer) + clearTimeout(this.initialLoadTimer); + if (this.canvasUpdateTimer) clearTimeout(this.canvasUpdateTimer); - } - - // Set a new timer with a small delay to batch updates - this.canvasUpdateTimer = setTimeout(() => { - this.node.setDirtyCanvas(true, false); - this.canvasUpdateTimer = null; - }, 50); // 50ms delay to batch multiple frame loads - } - - // New method for updating selected video pair - updateSelectedPair() { - if (this.maxPairs === 0) return; - - const pairIndex = Math.min(this.currentPairIndex, this.maxPairs - 1); - - // Update current video pair - this.videoA = this.videosA[pairIndex] || null; - this.videoB = this.videosB[pairIndex] || null; - this.framesA = this.videoA ? this.videoA.frames : []; - this.framesB = this.videoB ? this.videoB.frames : []; - this.fps = (this.videoA && this.videoA.fps) || (this.videoB && this.videoB.fps) || 8; - - // Reset frame index when switching pairs - this.currentFrameIndex = 0; - - // Clear frame caches for new pair this.loadedFramesA = {}; this.loadedFramesB = {}; - - // Preload frames for new pair - if (this.framesA.length > 0 || this.framesB.length > 0) { - this.preloadInitialFrames(); - } - - console.log(`[VideoComparer] Updated to pair ${pairIndex + 1}/${this.maxPairs}`); - } - - // New method: Draw grid mode for multiple video pairs - drawGridMode(ctx, y, width, availableHeight) { - const maxPairsToShow = Math.min(this.maxPairs, 8); // Limit to 8 pairs for performance - const cols = Math.ceil(Math.sqrt(maxPairsToShow * 2)); // 2 videos per pair - const rows = Math.ceil((maxPairsToShow * 2) / cols); - - const cellWidth = width / cols; - const cellHeight = (availableHeight - 40) / rows; // Reserve space for controls - - let cellIndex = 0; - - for (let pairIndex = 0; pairIndex < maxPairsToShow; pairIndex++) { - const videoA = this.videosA[pairIndex]; - const videoB = this.videosB[pairIndex]; - - // Calculate current frames for this pair - const currentFrameA = Math.min(this.currentFrameIndex, (videoA?.frames.length || 1) - 1); - const currentFrameB = Math.min(this.currentFrameIndex, (videoB?.frames.length || 1) - 1); - - // Draw video A - if (videoA && videoA.frames.length > 0) { - const frameImg = this.getFrameImageForPair(currentFrameA, "A", pairIndex); - const col = cellIndex % cols; - const row = Math.floor(cellIndex / cols); - const cellX = col * cellWidth; - const cellY = y + row * cellHeight; - - this.drawVideoInCell(ctx, frameImg, videoA.name, cellY, cellX, cellWidth, cellHeight); - cellIndex++; - } - - // Draw video B - if (videoB && videoB.frames.length > 0) { - const frameImg = this.getFrameImageForPair(currentFrameB, "B", pairIndex); - const col = cellIndex % cols; - const row = Math.floor(cellIndex / cols); - const cellX = col * cellWidth; - const cellY = y + row * cellHeight; - - this.drawVideoInCell(ctx, frameImg, videoB.name, cellY, cellX, cellWidth, cellHeight); - cellIndex++; - } - } - } - - // New method: Draw batch mode for multiple video pairs - drawBatchMode(ctx, y, width, availableHeight) { - const pairHeight = (availableHeight - 40) / this.pairsPerPage; // Reserve space for controls - const startPairIndex = this.currentBatchPage * this.pairsPerPage; - const endPairIndex = Math.min(startPairIndex + this.pairsPerPage, this.maxPairs); - - for (let i = 0; i < this.pairsPerPage; i++) { - const pairIndex = startPairIndex + i; - if (pairIndex >= this.maxPairs) break; - - const videoA = this.videosA[pairIndex]; - const videoB = this.videosB[pairIndex]; - const pairY = y + i * pairHeight; - - // Calculate current frames for this pair - const currentFrameA = Math.min(this.currentFrameIndex, (videoA?.frames.length || 1) - 1); - const currentFrameB = Math.min(this.currentFrameIndex, (videoB?.frames.length || 1) - 1); - - // Draw video A on the left - if (videoA && videoA.frames.length > 0) { - const frameImg = this.getFrameImageForPair(currentFrameA, "A", pairIndex); - this.drawVideoInPair(ctx, frameImg, videoA.name, pairY, 0, width / 2, pairHeight, 0); - } - - // Draw video B on the right - if (videoB && videoB.frames.length > 0) { - const frameImg = this.getFrameImageForPair(currentFrameB, "B", pairIndex); - this.drawVideoInPair(ctx, frameImg, videoB.name, pairY, width / 2, width / 2, pairHeight, 1); - } - - // Draw separator between pairs - if (i < this.pairsPerPage - 1 && pairIndex < this.maxPairs - 1) { - ctx.beginPath(); - ctx.moveTo(0, pairY + pairHeight); - ctx.lineTo(width, pairY + pairHeight); - ctx.strokeStyle = "rgba(255,255,255,0.3)"; - ctx.lineWidth = 1; - ctx.stroke(); - } - } - } - - // New method: Draw video frame in a cell (for grid mode) - drawVideoInCell(ctx, frameImg, videoName, y, x, cellWidth, cellHeight, label) { - if (!frameImg || !frameImg.complete || frameImg.failed) { - // Draw placeholder - ctx.fillStyle = "rgba(100,100,100,0.5)"; - ctx.fillRect(x + 2, y + 2, cellWidth - 4, cellHeight - 4); - ctx.fillStyle = "white"; - ctx.font = "12px Arial"; - ctx.textAlign = "center"; - ctx.fillText("Loading...", x + cellWidth/2, y + cellHeight/2); - return; - } - - const padding = 4; - const usableWidth = cellWidth - padding * 2; - const usableHeight = cellHeight - padding * 2; - - const imageAspect = frameImg.naturalWidth / frameImg.naturalHeight; - const cellAspect = usableWidth / usableHeight; - - let targetWidth, targetHeight; - - if (imageAspect > cellAspect) { - targetWidth = usableWidth; - targetHeight = usableWidth / imageAspect; - } else { - targetHeight = usableHeight; - targetWidth = usableHeight * imageAspect; - } - - const destX = x + padding + (usableWidth - targetWidth) / 2; - const destY = y + padding + (usableHeight - targetHeight) / 2; - - ctx.save(); - - // Draw border - ctx.strokeStyle = "rgba(255,255,255,0.5)"; - ctx.lineWidth = 1; - ctx.strokeRect(x + padding, y + padding, usableWidth, usableHeight); - - // Draw video frame - ctx.drawImage( - frameImg, - 0, 0, frameImg.naturalWidth, frameImg.naturalHeight, - destX, destY, targetWidth, targetHeight - ); - - // Draw label - ctx.fillStyle = "rgba(0,0,0,0.8)"; - ctx.fillRect(destX, destY, 25, 18); - ctx.fillStyle = "white"; - ctx.font = "11px Arial"; - ctx.textAlign = "center"; - ctx.fillText(videoName, destX + 12, destY + 13); - - ctx.restore(); - } - - // New method: Draw video frame in a pair (for batch mode) - drawVideoInPair(ctx, frameImg, videoName, y, x, pairWidth, pairHeight, videoIndex) { - if (!frameImg || !frameImg.complete || frameImg.failed) { - // Draw placeholder - ctx.fillStyle = "rgba(100,100,100,0.5)"; - ctx.fillRect(x + 2, y + 2, pairWidth - 4, pairHeight - 4); - ctx.fillStyle = "white"; - ctx.font = "12px Arial"; - ctx.textAlign = "center"; - ctx.fillText("Loading...", x + pairWidth/2, y + pairHeight/2); - return; - } - - const padding = 2; - const usableWidth = pairWidth - padding * 2; - const usableHeight = pairHeight - padding * 2; - - const imageAspect = frameImg.naturalWidth / frameImg.naturalHeight; - const pairAspect = usableWidth / usableHeight; - - let targetWidth, targetHeight; - - if (imageAspect > pairAspect) { - targetWidth = usableWidth; - targetHeight = usableWidth / imageAspect; - } else { - targetHeight = usableHeight; - targetWidth = usableHeight * imageAspect; - } - - const destX = x + padding + (usableWidth - targetWidth) / 2; - const destY = y + padding + (usableHeight - targetHeight) / 2; - - ctx.save(); - - // Draw video frame - ctx.drawImage( - frameImg, - 0, 0, frameImg.naturalWidth, frameImg.naturalHeight, - destX, destY, targetWidth, targetHeight - ); - - // Draw label - ctx.fillStyle = "rgba(0,0,0,0.8)"; - ctx.fillRect(destX, destY, 25, 18); - ctx.fillStyle = "white"; - ctx.font = "12px Arial"; - ctx.textAlign = "center"; - ctx.fillText(videoName, destX + 12, destY + 13); - - // Draw separator line for side-by-side in batch mode - if (videoIndex === 0 && pairWidth < this.node.size[0]) { - ctx.beginPath(); - ctx.moveTo(x + pairWidth, y); - ctx.lineTo(x + pairWidth, y + pairHeight); - ctx.strokeStyle = "rgba(255,255,255,0.5)"; - ctx.lineWidth = 1; - ctx.stroke(); - } - - ctx.restore(); - } - - // New method: Draw batch controls - drawBatchControls(ctx, y, width, availableHeight) { - const controlY = y + availableHeight - 30; - const mode = this.node.properties?.comparer_mode || "Playback"; - - ctx.save(); - - // Draw control background - ctx.fillStyle = "rgba(0,0,0,0.7)"; - ctx.fillRect(0, controlY, width, 30); - - ctx.fillStyle = "white"; - ctx.font = "12px Arial"; - ctx.textAlign = "left"; - - // Draw current frame info and controls - ctx.fillText(`Frame: ${this.currentFrameIndex + 1}`, 10, controlY + 18); - - // Play/pause button - const buttonWidth = 60; - const buttonHeight = 20; - const buttonY = controlY + 5; - const playButtonX = 80; - - ctx.fillStyle = this.isPlaying ? "rgba(200,100,100,0.8)" : "rgba(100,200,100,0.8)"; - ctx.fillRect(playButtonX, buttonY, buttonWidth, buttonHeight); - ctx.fillStyle = "white"; - ctx.textAlign = "center"; - ctx.fillText(this.isPlaying ? "⏸ Pause" : "▶ Play", playButtonX + buttonWidth/2, buttonY + 14); - - if (mode === "Grid") { - ctx.textAlign = "right"; - ctx.fillText(`Showing ${Math.min(this.maxPairs, 8)} pairs`, width - 10, controlY + 18); - } else if (mode === "Batch") { - // Batch navigation - ctx.fillStyle = "rgba(100,100,100,0.8)"; - ctx.fillRect(150, buttonY, buttonWidth, buttonHeight); - ctx.fillStyle = "white"; - ctx.textAlign = "center"; - ctx.fillText("◀ Prev", 150 + buttonWidth/2, buttonY + 14); - - ctx.fillStyle = "rgba(100,100,100,0.8)"; - ctx.fillRect(220, buttonY, buttonWidth, buttonHeight); - ctx.fillStyle = "white"; - ctx.fillText("Next ▶", 220 + buttonWidth/2, buttonY + 14); - - const startPair = this.currentBatchPage * this.pairsPerPage + 1; - const endPair = Math.min((this.currentBatchPage + 1) * this.pairsPerPage, this.maxPairs); - ctx.textAlign = "right"; - ctx.fillText(`Pairs ${startPair}-${endPair} of ${this.maxPairs}`, width - 10, controlY + 18); - } - - ctx.restore(); - } - - // New method: Get frame image for specific pair - getFrameImageForPair(frameIndex, videoId, pairIndex) { - const videos = videoId === "A" ? this.videosA : this.videosB; - const video = videos[pairIndex]; - - if (!video || !video.frames || frameIndex >= video.frames.length) { - return null; - } - - const frameData = video.frames[frameIndex]; - const cacheKey = `${videoId}_${pairIndex}_${frameData.frame_index}`; - - // Use same caching mechanism but with pair-specific keys - return this.loadFrame(frameData, `${videoId}_${pairIndex}`); - } - - // New method: Navigate to next batch page - nextBatchPage() { - if (this.currentBatchPage < this.maxBatchPages - 1) { - this.currentBatchPage++; - this.node.setDirtyCanvas(true, false); - } - } - - // New method: Navigate to previous batch page - previousBatchPage() { - if (this.currentBatchPage > 0) { - this.currentBatchPage--; - this.node.setDirtyCanvas(true, false); - } } } - +// === COMFYUI EXTENSION REGISTRATION === app.registerExtension({ name: "VideoComparer", - async beforeRegisterNodeDef(nodeType, nodeData, app) { - // Check for both possible node names - if (nodeData.name !== "Video Comparer" && nodeData.name !== "VideoComparer") { + async beforeRegisterNodeDef(nodeType, nodeData, _app) { + if (nodeData.name !== "Video Comparer" && nodeData.name !== "VideoComparer") return; - } - - // Add properties nodeType.prototype.properties = nodeType.prototype.properties || {}; nodeType.prototype.properties.comparer_mode = "Playback"; nodeType.prototype.properties.selected_video = "A"; nodeType.prototype.properties.onionSkinOpacity = 0.5; - nodeType.prototype.properties.user_resized = false; // Add flag to track if user has manually resized - - nodeType["@comparer_mode"] = { - type: "combo", - values: ["Playback", "Side-by-Side", "Stacked", "Slider", "Onion Skin", "Sync Compare"], - }; - - nodeType["@selected_video"] = { - type: "combo", - values: ["A", "B"], - }; - - // Store the original onDrawForeground function if it exists + nodeType.prototype.properties.user_resized = false; const origOnDrawForeground = nodeType.prototype.onDrawForeground; - - // Add our own onDrawForeground function for gradient background - nodeType.prototype.onDrawForeground = function(ctx) { - // Call the original onDrawForeground if it exists - if (origOnDrawForeground) { + nodeType.prototype.onDrawForeground = function (ctx) { + if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments); - } - - // Draw our custom gradient title drawGradientTitle(this, ctx); }; - - // Clean up resources when node is removed const origOnRemoved = nodeType.prototype.onRemoved; - nodeType.prototype.onRemoved = function() { - if (origOnRemoved) { + nodeType.prototype.onRemoved = function () { + if (origOnRemoved) origOnRemoved.apply(this, arguments); - } - - // Clean up widget resources - if (this.videoComparerWidget) { + if (this.videoComparerWidget) this.videoComparerWidget.onRemoved(); - } - - // Clear cached canvases to prevent memory leaks CACHE.titleCanvas = null; CACHE.titleCtx = null; - CACHE.collapsed.canvas = null; - CACHE.collapsed.ctx = null; }; - - // Initialize state variables - const onNodeCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = function() { - if (onNodeCreated) { - onNodeCreated.apply(this, arguments); - } - - // Ensure properties are properly initialized + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + if (origOnNodeCreated) + origOnNodeCreated.apply(this, arguments); this.properties = this.properties || {}; - if (!this.properties.comparer_mode) { - this.properties.comparer_mode = "Playback"; - } - if (!this.properties.selected_video) { - this.properties.selected_video = "A"; - } - if (this.properties.onionSkinOpacity === undefined) { - this.properties.onionSkinOpacity = 0.5; - } - - // Initialize state variables + this.properties.comparer_mode = this.properties.comparer_mode || "Playback"; + this.properties.selected_video = this.properties.selected_video || "A"; + this.properties.onionSkinOpacity = this.properties.onionSkinOpacity ?? 0.5; this.isPointerOver = false; this.pointerOverPos = [0, 0]; this.selectedVideo = "A"; - - // Add layout control widget - this.layoutWidget = this.addWidget("combo", "Comparison Mode", this.properties.comparer_mode, (value) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.layoutWidget = this.addWidget("combo", "Comparison Mode", this.properties.comparer_mode, ((value) => { this.properties.comparer_mode = value; - this.updateControlsVisibility(); + if (this.updateControlsVisibility) + this.updateControlsVisibility(); this.setDirtyCanvas(true, false); - }, { - values: ["Playback", "Side-by-Side", "Stacked", "Slider", "Onion Skin", "Sync Compare"] - }); - - // Add video selector widget for Playback mode - this.videoSelectorWidget = this.addWidget("combo", "Video", this.properties.selected_video, (value) => { + }), { values: ["Playback", "Side-by-Side", "Stacked", "Slider", "Onion Skin", "Sync Compare"] }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.videoSelectorWidget = this.addWidget("combo", "Video", this.properties.selected_video, ((value) => { this.properties.selected_video = value; this.selectedVideo = value; this.setDirtyCanvas(true, false); - }, { - values: ["A", "B"] - }); - - // Add Onion Skin opacity slider - this.onionSkinOpacitySlider = this.addWidget("slider", "Opacity B", this.properties.onionSkinOpacity, (value) => { - this.properties.onionSkinOpacity = parseFloat(value); + }), { values: ["A", "B"] }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.onionSkinOpacitySlider = this.addWidget("slider", "Opacity B", this.properties.onionSkinOpacity, ((value) => { + this.properties.onionSkinOpacity = value; this.setDirtyCanvas(true, false); - }, { - min: 0.0, - max: 1.0, - step: 0.01 - }); - - // Create the custom widget + }), { min: 0.0, max: 1.0, step: 0.01 }); this.videoComparerWidget = this.addCustomWidget(new VideoComparerWidget("video_comparer", this)); - - // Initialize controls visibility - this.updateControlsVisibility(); - - // Force node to be larger to accommodate the widget - const initialSize = this.computeSize(); - this.setSize([Math.max(400, initialSize[0]), Math.max(300, initialSize[1])]); - - // Force immediate redraw + if (this.updateControlsVisibility) + this.updateControlsVisibility(); + const initialSize = this.computeSize?.() || [400, 300]; + if (this.setSize) + this.setSize([Math.max(400, initialSize[0]), Math.max(300, initialSize[1])]); this.setDirtyCanvas(true, true); }; - - // Method to show/hide controls based on mode - nodeType.prototype.updateControlsVisibility = function() { + nodeType.prototype.updateControlsVisibility = function () { const mode = this.properties.comparer_mode; - - // Show video selector only in Playback mode - if (this.videoSelectorWidget) { + if (this.videoSelectorWidget) this.videoSelectorWidget.hidden = mode !== "Playback"; - } - - // Show opacity slider only in Onion Skin mode - if (this.onionSkinOpacitySlider) { + if (this.onionSkinOpacitySlider) this.onionSkinOpacitySlider.hidden = mode !== "Onion Skin"; - } }; - - // Override computeSize to account for the widget - const originalComputeSize = nodeType.prototype.computeSize; - nodeType.prototype.computeSize = function(out) { - const size = originalComputeSize ? originalComputeSize.apply(this, arguments) : [400, 300]; + const origComputeSize = nodeType.prototype.computeSize; + nodeType.prototype.computeSize = function (out) { + const size = origComputeSize ? origComputeSize.apply(this, arguments) : [400, 300]; if (this.videoComparerWidget) { const widgetSize = this.videoComparerWidget.computeSize(size[0]); - - // Calculate additional space needed for controls - let extraHeight = 60; // Base padding for layout widget + title bar - - const mode = this.properties.comparer_mode; - - if (mode === "Playback") { - extraHeight += 30; // Additional space for video selector - } else if (mode === "Onion Skin") { - extraHeight += 30; // Additional space for opacity slider - } - + let extraHeight = 60; + if (this.properties.comparer_mode === "Playback" || this.properties.comparer_mode === "Onion Skin") + extraHeight += 30; size[1] = Math.max(size[1], widgetSize[1] + extraHeight); } return size; }; - - // Override onExecuted to handle video data - const originalOnExecuted = nodeType.prototype.onExecuted; - nodeType.prototype.onExecuted = function(message) { - console.log("[VideoComparer] onExecuted called!", this.id); - console.log("[VideoComparer] Message type:", typeof message); - console.log("[VideoComparer] Message structure:", message ? Object.keys(message).join(", ") : "null"); - - if (message && message.ui) { - console.log("[VideoComparer] UI data structure:", message.ui ? Object.keys(message.ui).join(", ") : "no ui object"); + const origOnExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + if (origOnExecuted) + origOnExecuted.apply(this, arguments); + if (!message || typeof message !== 'object') + return message; + let videoData = message.ui?.video_data || message.video_data; + if (videoData && this.videoComparerWidget) { + this.videoComparerWidget.value = { + video_data: videoData, + difference_data: message.ui?.difference_data || {}, + histogram_data: message.ui?.histogram_data || [] + }; + this.setDirtyCanvas(true, true); } - - // Call the original onExecuted first - let result; - if (originalOnExecuted) { - console.log("[VideoComparer] Calling original onExecuted"); - result = originalOnExecuted.apply(this, arguments); - console.log("[VideoComparer] Original onExecuted returned:", result ? "result object" : "falsy value"); - } - - // Now handle our custom logic - if (message && typeof message === 'object') { - console.log("[VideoComparer] Processing message for custom widget"); - console.log("[VideoComparer] Full message:", JSON.stringify(message, null, 2)); - - // Check for video data in different possible locations - let videoData = null; - if (message && message.ui && message.ui.video_data) { - videoData = message.ui.video_data; - console.log("[VideoComparer] Found video_data in message.ui:", videoData); - } else if (message && message.video_data) { - videoData = message.video_data; - console.log("[VideoComparer] Found video_data in message root:", videoData); - } else { - console.log("[VideoComparer] No video_data found. Message structure:"); - console.log("[VideoComparer] message.ui keys:", message.ui ? Object.keys(message.ui) : "no ui"); - console.log("[VideoComparer] message root keys:", message ? Object.keys(message) : "no message"); - if (message && message.ui) { - console.log("[VideoComparer] message.ui content:", message.ui); - } - } - - if (videoData) { - console.log("[VideoComparer] Processing video data with", videoData.length, "videos"); - - // Extract ALL the data types from the message - const differenceData = message.ui && message.ui.difference_data ? message.ui.difference_data : {}; - const histogramData = message.ui && message.ui.histogram_data ? message.ui.histogram_data : []; - - console.log("[VideoComparer] Extracted difference data:", differenceData); - console.log("[VideoComparer] Extracted histogram data:", histogramData); - - if (this.videoComparerWidget) { - console.log("[VideoComparer] Setting widget value, widget exists:", !!this.videoComparerWidget); - - // Pass ALL the data to the widget - this.videoComparerWidget.value = { - video_data: videoData, - difference_data: differenceData, - histogram_data: histogramData - }; - - console.log("[VideoComparer] Widget value set, triggering redraw"); - - // Don't force a resize, just redraw - this.setDirtyCanvas(true, true); - - // Double check that widget is attached and visible - console.log("[VideoComparer] Widget after value set:", - this.videoComparerWidget ? - `Widget exists, name: ${this.videoComparerWidget.name}, y: ${this.videoComparerWidget.y}` : - "Widget doesn't exist"); - } else { - console.error("[VideoComparer] No videoComparerWidget found on node!"); - console.log("[VideoComparer] All widgets:", this.widgets ? this.widgets.map(w => w.name).join(', ') : "No widgets array"); - - // Try to create the widget if it doesn't exist - if (!this.widgets || !this.widgets.find(w => w.name === "video_comparer")) { - console.log("[VideoComparer] Attempting to create missing widget"); - this.videoComparerWidget = this.addCustomWidget(new VideoComparerWidget("video_comparer", this)); - - if (this.videoComparerWidget) { - console.log("[VideoComparer] Created widget, setting value"); - this.videoComparerWidget.value = { - video_data: videoData, - difference_data: differenceData, - histogram_data: histogramData - }; - // Don't force a resize, just redraw - this.setDirtyCanvas(true, true); - } - } - } - } else { - console.log("[VideoComparer] No valid video data found, checking if we have standard images"); - - // Check if we have standard animated images and convert them for testing - if (message && message.ui && message.ui.images && message.ui.images.length > 0) { - console.log("[VideoComparer] Found standard images, creating test video data"); - - // Create fake video data structure for testing - videoData = [{ - name: "video_a", - frames: message.ui.images.map((img, index) => ({ - filename: img.filename, - subfolder: img.subfolder || "", - type: img.type || "output", - frame_index: index - })), - fps: 8 - }]; - - console.log("[VideoComparer] Created test video data:", videoData); - } - } - } else { - console.warn("[VideoComparer] Message is not a valid object:", message); - } - - return result || message; + return message; }; - - // Mouse event handlers - nodeType.prototype.onMouseDown = function(event, pos, canvas) { - if (this.videoComparerWidget) { - return this.videoComparerWidget.mouse(event, pos, this); - } - return false; + nodeType.prototype.onMouseDown = function (event, pos) { + return this.videoComparerWidget?.mouse(event, pos, this) || false; }; - - // Track when user manually resizes the node - const originalOnResize = nodeType.prototype.onResize; - nodeType.prototype.onResize = function(size) { - // Call original resize handler if it exists - if (originalOnResize) { - originalOnResize.apply(this, arguments); - } - - // Mark that user has manually resized - this.properties.user_resized = true; - }; - - nodeType.prototype.onMouseUp = function(event, pos, canvas) { - if (this.videoComparerWidget) { - return this.videoComparerWidget.mouse(event, pos, this); - } - return false; - }; - - nodeType.prototype.onMouseMove = function(event, pos, canvas) { - if (this.videoComparerWidget) { - return this.videoComparerWidget.mouse(event, pos, this); - } - return false; - }; - - nodeType.prototype.onMouseEnter = function(event) { + nodeType.prototype.onMouseEnter = function () { this.isPointerOver = true; this.setDirtyCanvas(true, false); }; - - nodeType.prototype.onMouseLeave = function(event) { + nodeType.prototype.onMouseLeave = function () { this.isPointerOver = false; this.setDirtyCanvas(true, false); }; - - // Keydown event handler for node - nodeType.prototype.onKeyDown = function(event) { - if (!this.videoComparerWidget) return false; - - let handled = false; + nodeType.prototype.onMouseMove = function (event, pos) { + this.pointerOverPos = pos; + if (this.properties.comparer_mode === "Slider") + this.setDirtyCanvas(true, false); + return false; + }; + nodeType.prototype.onKeyDown = function (event) { + if (!this.videoComparerWidget) + return false; switch (event.key) { case "ArrowLeft": this.videoComparerWidget.previousFrame(); - handled = true; - break; + return true; case "ArrowRight": this.videoComparerWidget.nextFrame(); - handled = true; - break; - case " ": // Space bar + return true; + case " ": this.videoComparerWidget.togglePlayback(); - handled = true; - break; - case "p": - case "P": - if (event.altKey) { - this.videoComparerWidget.togglePlayback(); - handled = true; - } - break; + event.preventDefault(); + return true; } - - if (handled) { - event.preventDefault(); // Prevent default browser action (e.g., scrolling with space) - event.stopPropagation(); // Stop event from bubbling up - return true; // Indicate that the event was handled - } - return false; // Event not handled by this node + return false; }; - - // Add context menu options - const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; - nodeType.prototype.getExtraMenuOptions = function(_, options) { - if (getExtraMenuOptions) { - getExtraMenuOptions.apply(this, arguments); - } - - // Add separator before our options + const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + if (origGetExtraMenuOptions) + origGetExtraMenuOptions.apply(this, arguments); options.push(null); - - // Add comparison mode submenu const currentMode = this.properties.comparer_mode || "Playback"; const modes = ["Playback", "Side-by-Side", "Stacked", "Slider", "Onion Skin", "Sync Compare"]; - options.push({ content: "Comparison Mode", has_submenu: true, @@ -2321,82 +1007,26 @@ app.registerExtension({ content: mode === currentMode ? `✓ ${mode}` : mode, callback: () => { this.properties.comparer_mode = mode; - if (this.layoutWidget) { + if (this.layoutWidget) this.layoutWidget.value = mode; - } - this.updateControlsVisibility(); + if (this.updateControlsVisibility) + this.updateControlsVisibility(); this.setDirtyCanvas(true, false); } })) } }); - - // Add video selection submenu (only show when relevant) - if (this.properties.comparer_mode === "Playback") { - const currentVideo = this.properties.selected_video || "A"; - const videos = ["A", "B"]; - - options.push({ - content: "Select Video", - has_submenu: true, - submenu: { - options: videos.map(video => ({ - content: video === currentVideo ? `✓ Video ${video}` : `Video ${video}`, - callback: () => { - this.properties.selected_video = video; - this.selectedVideo = video; - if (this.videoSelectorWidget) { - this.videoSelectorWidget.value = video; - } - this.setDirtyCanvas(true, false); - } - })) - } - }); - } - - // Add opacity controls for Onion Skin mode - if (this.properties.comparer_mode === "Onion Skin") { - const opacityPresets = [ - { label: "25%", value: 0.25 }, - { label: "50%", value: 0.5 }, - { label: "75%", value: 0.75 } - ]; - - options.push({ - content: "Onion Skin Opacity", - has_submenu: true, - submenu: { - options: opacityPresets.map(preset => ({ - content: preset.label, - callback: () => { - this.properties.onionSkinOpacity = preset.value; - if (this.onionSkinOpacitySlider) { - this.onionSkinOpacitySlider.value = preset.value; - } - this.setDirtyCanvas(true, false); - } - })) - } - }); - } - - // Add separator before existing options - options.push(null); - - options.push( - { - content: "Reset to Default Size", - callback: () => { - // Reset the user_resized flag - this.properties.user_resized = false; + options.push({ + content: "Reset to Default Size", + callback: () => { + this.properties.user_resized = false; + if (this.setSize && this.computeSize) this.setSize(this.computeSize()); - this.setDirtyCanvas(true, false); - } + this.setDirtyCanvas(true, false); } - ); + }); }; - console.log("[VideoComparer] Node setup complete"); } -}); \ No newline at end of file +}); +//# sourceMappingURL=video_comparer.js.map \ No newline at end of file