import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; /** * Custom widget for ImageColorSampler that allows clicking on an image to sample colors */ app.registerExtension({ name: "ComfyUI.Enrico.ImageSampler", async setup() { // Listen for image_sampler_init event from Python api.addEventListener("image_sampler_init", (event) => { const detail = event.detail; const node = app.graph.getNodeById(detail.node); if (!node) return; // Forward the data to the node instance if (node.onImageSamplerInit) { node.onImageSamplerInit(detail.data); } }); // Listen for image_sampler_update event from Python api.addEventListener("image_sampler_update", (event) => { const detail = event.detail; const node = app.graph.getNodeById(detail.node); if (!node) return; // Update the widget with new value const widget = node.widgets.find(w => w.name === detail.widget_name); if (widget) { widget.value = detail.value; app.graph.setDirtyCanvas(true); // Run the workflow again to continue processing app.queuePrompt(0, 1); // Continue the workflow } }); }, async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeType.comfyClass === "ImageColorSampler") { const onNodeCreated = nodeType.prototype.onNodeCreated; // Override the onNodeCreated method to add our custom widget nodeType.prototype.onNodeCreated = function() { const result = onNodeCreated?.apply(this, arguments); // Get references to the input widgets const samplePointsWidget = this.widgets.find(w => w.name === "sample_points"); const paletteSizeWidget = this.widgets.find(w => w.name === "palette_size"); const sampleSizeWidget = this.widgets.find(w => w.name === "sample_size"); const waitForInputWidget = this.widgets.find(w => w.name === "wait_for_input"); // Hide the sample_points widget as it's just for data storage if (samplePointsWidget) { samplePointsWidget.computeSize = () => [0, -4]; } // Create main container for our custom widget const container = document.createElement("div"); container.style.width = "auto"; container.style.height = "auto"; container.style.display = "flex"; container.style.flexDirection = "column"; container.style.padding = "10px"; container.style.resize = "none"; container.style.overflow = "hidden"; // Create image container const imageContainer = document.createElement("div"); imageContainer.style.backgroundColor = "#333"; imageContainer.style.border = "1px solid #666"; imageContainer.style.borderRadius = "4px"; imageContainer.style.cursor = "crosshair"; imageContainer.style.display = "block"; imageContainer.style.width = "auto"; imageContainer.style.margin = "0 auto"; imageContainer.style.resize = "none"; imageContainer.style.overflow = "hidden"; // Create canvas for image display and interaction const canvas = document.createElement("canvas"); canvas.width = 512; // Initial size, will be updated when image loads canvas.height = 512; imageContainer.appendChild(canvas); // Create debug info element to show coordinates const debugInfo = document.createElement("div"); debugInfo.style.backgroundColor = "rgba(0,0,0,0.5)"; debugInfo.style.color = "#fff"; debugInfo.style.padding = "5px"; debugInfo.style.borderRadius = "3px"; debugInfo.style.fontSize = "12px"; debugInfo.style.margin = "5px"; debugInfo.style.display = "block"; // Set to "none" to hide in production container.appendChild(debugInfo); // Create info panel const infoPanel = document.createElement("div"); infoPanel.style.marginTop = "10px"; infoPanel.style.padding = "8px"; infoPanel.style.backgroundColor = "#333"; infoPanel.style.borderRadius = "4px"; infoPanel.style.fontSize = "12px"; infoPanel.style.color = "#ccc"; infoPanel.innerHTML = "Click on image to add color samples
Drag points to move
CTRL+Click to remove a point
Adjust sample size to average colors
Click 'Continue Workflow' to proceed"; // Create buttons container const buttonsContainer = document.createElement("div"); buttonsContainer.style.marginTop = "10px"; buttonsContainer.style.display = "flex"; buttonsContainer.style.gap = "10px"; // Create clear button const clearButton = document.createElement("button"); clearButton.textContent = "Clear Samples"; clearButton.style.padding = "6px 12px"; clearButton.style.backgroundColor = "#555"; clearButton.style.color = "white"; clearButton.style.border = "none"; clearButton.style.borderRadius = "4px"; clearButton.style.cursor = "pointer"; // Create continue button const continueButton = document.createElement("button"); continueButton.textContent = "Continue Workflow"; continueButton.style.padding = "6px 12px"; continueButton.style.backgroundColor = "#3a88fe"; continueButton.style.color = "white"; continueButton.style.border = "none"; continueButton.style.borderRadius = "4px"; continueButton.style.cursor = "pointer"; continueButton.style.marginLeft = "auto"; // Add hover effect for buttons [clearButton, continueButton].forEach(button => { button.addEventListener("mouseover", function() { this.style.opacity = "0.8"; }); button.addEventListener("mouseout", function() { this.style.opacity = "1"; }); }); // Add buttons to container buttonsContainer.appendChild(clearButton); buttonsContainer.appendChild(continueButton); // Add elements to container in the correct order container.appendChild(imageContainer); container.appendChild(infoPanel); container.appendChild(buttonsContainer); // Sample points data let samplePoints = []; const pointSize = 5; // Radius of sample points // Canvas context and state const ctx = canvas.getContext("2d"); let image = null; let imageBase64 = null; let selectedPoint = -1; let isDragging = false; let nodeId = null; // Store actual dimensions of the original image let originalImageWidth = 0; let originalImageHeight = 0; // Method to handle data from Python this.onImageSamplerInit = (data) => { if (!data) return; // Store node ID for API calls nodeId = data.node_id; // Load points if any if (data.sample_points && Array.isArray(data.sample_points)) { samplePoints = data.sample_points; } // Update sample size if provided if (data.sample_size && sampleSizeWidget) { sampleSizeWidget.value = data.sample_size; } // Load image if provided if (data.image) { imageBase64 = data.image; loadImageFromBase64(data.image); } }; // Load and display image from base64 const loadImageFromBase64 = (base64Data) => { const img = new Image(); img.onload = () => { // Set canvas size to exactly match the image dimensions originalImageWidth = img.width; originalImageHeight = img.height; // Set canvas dimensions to match the image exactly canvas.width = img.width; canvas.height = img.height; // Adjust container size to fit the image exactly imageContainer.style.width = img.width + "px"; imageContainer.style.height = img.height + "px"; // Draw the image at 1:1 pixel ratio ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0); // Store image reference image = img; // Draw sample points if any drawSamplePoints(); }; img.src = base64Data; }; // Function to continue workflow const continueWorkflow = () => { if (!nodeId) return; // Send data back to server to continue the workflow api.fetchApi("/image_sampler/continue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: nodeId, sample_points: samplePoints }) }).catch(err => console.error("Error continuing workflow:", err)); }; // Draw sample points const drawSamplePoints = () => { if (!ctx || !canvas.width) return; // Redraw the image if (image) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(image, 0, 0, canvas.width, canvas.height); } // Draw each sample point samplePoints.forEach((point, index) => { // Convert normalized coordinates to canvas pixels const x = Math.round(point.x * canvas.width); const y = Math.round(point.y * canvas.height); // Draw outer circle ctx.beginPath(); ctx.arc(x, y, pointSize + 2, 0, Math.PI * 2); ctx.fillStyle = "black"; ctx.fill(); // Draw inner circle with sampled color ctx.beginPath(); ctx.arc(x, y, pointSize, 0, Math.PI * 2); ctx.fillStyle = point.color || "#ffffff"; ctx.fill(); // Highlight selected point if (index === selectedPoint) { ctx.beginPath(); ctx.arc(x, y, pointSize + 4, 0, Math.PI * 2); ctx.strokeStyle = "yellow"; ctx.lineWidth = 2; ctx.stroke(); } // Display color information next to the point with a fixed-size background const hexColor = point.color || "#ffffff"; // Parse hex to RGB values const r = parseInt(hexColor.substring(1, 3), 16); const g = parseInt(hexColor.substring(3, 5), 16); const b = parseInt(hexColor.substring(5, 7), 16); const rgbText = `(${r}, ${g}, ${b})`; // Fixed position with consistent offset from the point const labelX = x; // Increase separation between point and label const labelY = y + pointSize + 25; const padding = 8; // Use a more readable font family with fallbacks const fontFamily = "'Segoe UI', Roboto, 'Helvetica Neue', sans-serif"; // Draw better looking label background with rounded corners const cornerRadius = 4; ctx.save(); // Save context state before modifications // Fixed width for the background based on max possible text size // "#FFFFFF" + "(255, 255, 255)" with proper padding const fixedLabelWidth = 100; // Fixed width that accommodates all possible values const fixedLabelHeight = 42; // Fixed height for consistent appearance // Create rounded rectangle background for color label ctx.fillStyle = "rgba(0,0,0,0.75)"; // Darker, more opaque background roundRect( ctx, labelX - fixedLabelWidth/2, // Center the fixed-width box labelY - 15, fixedLabelWidth, fixedLabelHeight, cornerRadius ); // Apply text rendering optimizations ctx.textBaseline = "middle"; ctx.shadowColor = "rgba(0,0,0,0.5)"; ctx.shadowBlur = 3; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 1; // Draw hex text with improved visibility ctx.font = `bold 13px ${fontFamily}`; ctx.textAlign = "center"; ctx.fillStyle = "#ffffff"; ctx.fillText(hexColor, labelX, labelY); // Draw RGB text below hex text ctx.font = `11px ${fontFamily}`; ctx.fillStyle = "#cccccc"; // Slightly dimmer for secondary info ctx.fillText(rgbText, labelX, labelY + 16); ctx.restore(); // Restore context to previous state }); }; // Helper function to create rounded rectangle path const roundRect = (context, x, y, width, height, radius) => { if (width < 2 * radius) radius = width / 2; if (height < 2 * radius) radius = height / 2; context.beginPath(); context.moveTo(x + radius, y); context.arcTo(x + width, y, x + width, y + height, radius); context.arcTo(x + width, y + height, x, y + height, radius); context.arcTo(x, y + height, x, y, radius); context.arcTo(x, y, x + width, y, radius); context.closePath(); context.fill(); }; // Check if a point is under the cursor const getPointAtPosition = (x, y) => { for (let i = samplePoints.length - 1; i >= 0; i--) { const point = samplePoints[i]; const canvasPos = { x: point.x * canvas.width, y: point.y * canvas.height }; const distance = Math.sqrt( Math.pow(x - canvasPos.x, 2) + Math.pow(y - canvasPos.y, 2) ); if (distance <= pointSize * 2) { // Slightly larger hit area for easier selection return i; } } return -1; }; // Get pixel color at a specific position const getPixelColorAtPosition = (x, y) => { if (!ctx || !image) return "#FFFFFF"; try { // Ensure coordinates are integers and within canvas bounds const pixelX = Math.max(0, Math.min(canvas.width - 1, Math.floor(x))); const pixelY = Math.max(0, Math.min(canvas.height - 1, Math.floor(y))); // Save context state ctx.save(); // Clear the canvas and draw just the image without any overlays ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(image, 0, 0, canvas.width, canvas.height); // Get the exact pixel data - no averaging or interpolation const pixelData = ctx.getImageData(pixelX, pixelY, 1, 1).data; // Restore context to previous state ctx.restore(); // Get exact RGB values (as integers) const r = pixelData[0]; const g = pixelData[1]; const b = pixelData[2]; // Format for hex display const rHex = r.toString(16).padStart(2, '0'); const gHex = g.toString(16).padStart(2, '0'); const bHex = b.toString(16).padStart(2, '0'); // Return the hex representation return `#${rHex}${gHex}${bHex}`; } catch (e) { console.error("Error getting pixel color:", e); return "#FFFFFF"; } }; // Update debug info display const updateDebugInfo = (info) => { if (debugInfo) { debugInfo.textContent = info; } }; // Handle mouse events on canvas canvas.addEventListener("mousedown", (e) => { e.preventDefault(); // Prevent default browser behavior // Use offsetX and offsetY directly for mouse position const mouseX = e.offsetX; const mouseY = e.offsetY; updateDebugInfo(`Mouse: ${mouseX.toFixed(1)}, ${mouseY.toFixed(1)}`); // Check if click is on an existing point const pointIndex = getPointAtPosition(mouseX, mouseY); if (pointIndex >= 0) { if (e.ctrlKey) { // CTRL+click to delete point samplePoints.splice(pointIndex, 1); selectedPoint = -1; updateSamplePointsWidget(); drawSamplePoints(); } else { // Select point for dragging selectedPoint = pointIndex; isDragging = true; drawSamplePoints(); } } else if (mouseX >= 0 && mouseX <= canvas.width && mouseY >= 0 && mouseY <= canvas.height && image) { // Calculate normalized coordinates for the new point const normalized = { x: mouseX / canvas.width, y: mouseY / canvas.height }; // Add new point const newPoint = { x: normalized.x, y: normalized.y, color: getPixelColorAtPosition(mouseX, mouseY) // Sample color immediately }; samplePoints.push(newPoint); selectedPoint = samplePoints.length - 1; isDragging = true; updateSamplePointsWidget(); drawSamplePoints(); } }); canvas.addEventListener("mousemove", (e) => { if (!isDragging || selectedPoint < 0) return; e.preventDefault(); // Use offsetX and offsetY directly for mouse position const mouseX = e.offsetX; const mouseY = e.offsetY; // Calculate normalized coordinates const normalized = { x: mouseX / canvas.width, y: mouseY / canvas.height }; // Update point position with normalized coordinates samplePoints[selectedPoint].x = normalized.x; samplePoints[selectedPoint].y = normalized.y; // Update debug info updateDebugInfo(`Point: (${normalized.x.toFixed(3)}, ${normalized.y.toFixed(3)})`); // First redraw the image to get accurate sampling if (image) { // Redraw the point's area to ensure we're sampling from the original image ctx.save(); ctx.drawImage(image, 0, 0, canvas.width, canvas.height); ctx.restore(); } // Update color at new position samplePoints[selectedPoint].color = getPixelColorAtPosition(mouseX, mouseY); // Then draw all sample points drawSamplePoints(); }); const handleMouseUp = (e) => { if (isDragging && selectedPoint >= 0) { e.preventDefault(); isDragging = false; updateSamplePointsWidget(); } }; // Track mouse position for debugging canvas.addEventListener("mousemove", (e) => { if (debugInfo.style.display !== "block") return; // Use offsetX and offsetY directly const mouseX = e.offsetX; const mouseY = e.offsetY; const normalized = { x: mouseX / canvas.width, y: mouseY / canvas.height }; updateDebugInfo( `Mouse: ${mouseX.toFixed(1)}, ${mouseY.toFixed(1)} | ` ); }); canvas.addEventListener("mouseup", handleMouseUp); canvas.addEventListener("mouseleave", handleMouseUp); // Update the hidden widget with sample points data const updateSamplePointsWidget = () => { if (samplePointsWidget) { samplePointsWidget.value = JSON.stringify(samplePoints); this.setDirtyCanvas(true); } }; // Extract hex codes as a list from sample points const getHexCodesList = () => { return samplePoints.map(point => point.color); }; // Clear all sample points clearButton.addEventListener("click", () => { samplePoints = []; selectedPoint = -1; updateSamplePointsWidget(); drawSamplePoints(); }); // Continue workflow button continueButton.addEventListener("click", () => { continueWorkflow(); }); // When the node receives the output from processing this.onExecuted = function(output) { if (!output || !output.hasResult) return; // If we have a sampled colors result, update point colors if (output.sampled_colors) { try { const colors = JSON.parse(output.sampled_colors); if (Array.isArray(colors) && colors.length > 0) { // Update point colors colors.forEach((colorData, index) => { if (index < samplePoints.length) { samplePoints[index].color = colorData.hex; } }); drawSamplePoints(); } } catch (e) { console.error("Error parsing sampled colors:", e); } } }; // Handle image input changes this.onImageInput = function(inputData) { // Schedule image loading for next frame to ensure DOM is ready if (inputData && inputData.tensor) { setTimeout(() => loadImageToCanvas(inputData), 0); } else if (imageBase64) { // If we have a base64 image from Python, use that setTimeout(() => loadImageFromBase64(imageBase64), 0); } }; // Load and display image from tensor data const loadImageToCanvas = (imgData) => { if (!imgData) return; // Store original image dimensions originalImageWidth = imgData.width; originalImageHeight = imgData.height; // Create image from tensor data const imgPixels = new Uint8ClampedArray(imgData.data); const imgDataObj = new ImageData(imgPixels, imgData.width, imgData.height); const offscreenCanvas = new OffscreenCanvas(imgData.width, imgData.height); const offCtx = offscreenCanvas.getContext("2d"); offCtx.putImageData(imgDataObj, 0, 0); // Create image object from the offscreen canvas offscreenCanvas.convertToBlob().then(blob => { const img = new Image(); img.onload = () => { // Set canvas size to exactly match the image dimensions canvas.width = img.width; canvas.height = img.height; // Adjust container size to fit the image exactly imageContainer.style.width = img.width + "px"; imageContainer.style.height = img.height + "px"; // Draw the image at 1:1 pixel ratio ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0); // Store image reference image = img; // Draw sample points if any drawSamplePoints(); }; img.src = URL.createObjectURL(blob); }); }; // Handle window resize to reposition points correctly const resizeObserver = new ResizeObserver(() => { if (image) { // Don't allow resizing - maintain original dimensions canvas.width = originalImageWidth; canvas.height = originalImageHeight; imageContainer.style.width = originalImageWidth + "px"; imageContainer.style.height = originalImageHeight + "px"; // Redraw everything at the original size ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(image, 0, 0, canvas.width, canvas.height); drawSamplePoints(); } }); // Start observing size changes resizeObserver.observe(imageContainer); // Add the DOM widget to the node this.addDOMWidget("image_sampler_widget", "image_sampler", container, { serialize: false, hideOnZoom: false, resizable: false }); // Make the input widgets smaller if (paletteSizeWidget) paletteSizeWidget.computeSize = () => [100, 24]; if (sampleSizeWidget) paletteSizeWidget.computeSize = () => [100, 24]; if (waitForInputWidget) waitForInputWidget.computeSize = () => [100, 24]; return result; }; // Add method to capture image input data const onConnectionsChange = nodeType.prototype.onConnectionsChange; nodeType.prototype.onConnectionsChange = function(type, slotIndex, isConnected, link_info, output) { const result = onConnectionsChange?.apply(this, arguments); // Process only when connecting an input and link_info is valid if (type === LiteGraph.INPUT && isConnected && link_info && link_info.origin_id) { // Get the linked node const linkedNode = this.graph.getNodeById(link_info.origin_id); if (linkedNode) { const inputSlot = link_info.origin_slot; const outputData = linkedNode.outputs[inputSlot]; // Check if this is an image input if (outputData && outputData.type === "IMAGE") { // Access the tensor data if available const tensorData = linkedNode.getOutputData ? linkedNode.getOutputData(inputSlot) : null; if (tensorData && this.onImageInput) { this.onImageInput({ tensor: tensorData }); } } } } else if (type === LiteGraph.INPUT && !isConnected) { // If image input is disconnected, clear the canvas const widget = this.widgets.find(w => w.name === "image_sampler_widget"); if (widget && widget.value) { const canvas = widget.value.querySelector("canvas"); if (canvas) { const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); } } } return result; }; // Ensure the node updates when new image data is available const onExecute = nodeType.prototype.onExecute; nodeType.prototype.onExecute = function() { const result = onExecute?.apply(this, arguments); // Check if we have image input const imageInput = this.getInputData(0); if (imageInput && this.onImageInput) { this.onImageInput({ tensor: imageInput }); } return result; }; } }, });