const COMFY_HOST = "127.0.0.1:8188"; let currentStroke = []; const canvas = document.getElementById("canvas"); canvas.addEventListener('contextmenu', e => e.preventDefault()); const ctx = canvas.getContext("2d", { alpha: false }); // ========== Cached DOM References ========== const DOM = { canvas: canvas, modeBtn: null, brushGroup: null, tileSizeGroup: null, brushSlider: null, brushSizeDisplay: null, featherInput: null, promptInput: null, toggleMaskBtn: null, sizeSelector: null, customSizeCheckbox: null, customWidth: null, customHeight: null, glowOverlay: null, // Initialize all cached references init() { this.modeBtn = document.getElementById("modeBtn"); this.brushGroup = document.getElementById("brushGroup"); this.tileSizeGroup = document.getElementById("tileSizeGroup"); this.brushSlider = document.getElementById("brushSlider"); this.brushSizeDisplay = document.getElementById("brushSizeDisplay"); this.featherInput = document.getElementById("featherInput"); this.promptInput = document.getElementById("promptInput"); this.toggleMaskBtn = document.getElementById("toggleMaskBtn"); this.sizeSelector = document.getElementById("sizeSelector"); this.customSizeCheckbox = document.getElementById("customSizeCheckbox"); this.customWidth = document.getElementById("customWidth"); this.customHeight = document.getElementById("customHeight"); this.glowOverlay = document.getElementById("glow-overlay"); } }; // ========== Utility Functions ========== // Throttle function for performance function throttle(func, limit) { let inThrottle; return function(...args) { if (!inThrottle) { func.apply(this, args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } }; } // Debounce function for resize function debounce(func, wait) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } // Cache for last successful inference result let lastInferenceResult = null; // WebSocket connection let ws = null; let wsReconnectAttempts = 0; const WS_MAX_RECONNECT_ATTEMPTS = 5; let pendingInference = null; // Store {tx, ty} for current inference let receivedProgressThisInference = false; // Track if we got progress messages let historyStack = []; let redoStack = []; const MAX_HISTORY = 50; /** * Capture a deep copy of the images array state. * Call this BEFORE any destructive action (paste, drop, erase). */ // Add this helper function to create a deep copy of the state function saveUndoState() { // Deep copy images including their canvas data const stateSnapshot = images.map(imgObj => { // Create a copy of the canvas const canvasCopy = document.createElement('canvas'); canvasCopy.width = imgObj.img.width || imgObj.w; canvasCopy.height = imgObj.img.height || imgObj.h; const ctx = canvasCopy.getContext('2d'); ctx.drawImage(imgObj.img, 0, 0); return { img: canvasCopy, x: imgObj.x, y: imgObj.y, w: imgObj.w, h: imgObj.h }; }); console.log(`[Undo] Saved canvas state with ${stateSnapshot.length} images`); historyStack.push({ type: 'canvas_snapshot', state: stateSnapshot }); if (historyStack.length > MAX_HISTORY) historyStack.shift(); // Clear redo stack when new action is performed redoStack = []; } // Save complete mask state for undo function saveMaskState() { const maskSnapshot = new Map(); maskChunks.forEach((chunk, key) => { // Use chunk's actual dimensions to ensure proper copy const tempCanvas = document.createElement('canvas'); tempCanvas.width = chunk.width; tempCanvas.height = chunk.height; const ctx = tempCanvas.getContext('2d'); ctx.drawImage(chunk, 0, 0); maskSnapshot.set(key, tempCanvas); }); console.log(`[Undo] Saved mask state with ${maskSnapshot.size} chunks`); historyStack.push({ type: 'mask_snapshot', maskState: maskSnapshot }); if (historyStack.length > MAX_HISTORY) historyStack.shift(); // Clear redo stack when new action is performed redoStack = []; } function performUndo() { if (historyStack.length === 0 || isInferencing) return; const lastAction = historyStack.pop(); if (lastAction.type === 'canvas_snapshot') { // Deep copy current state to redo before changing const currentState = images.map(imgObj => { const canvasCopy = document.createElement('canvas'); canvasCopy.width = imgObj.img.width || imgObj.w; canvasCopy.height = imgObj.img.height || imgObj.h; const ctx = canvasCopy.getContext('2d'); ctx.drawImage(imgObj.img, 0, 0); return { img: canvasCopy, x: imgObj.x, y: imgObj.y, w: imgObj.w, h: imgObj.h }; }); redoStack.push({ type: 'canvas_snapshot', state: currentState }); if (redoStack.length > MAX_HISTORY) redoStack.shift(); console.log(`[Undo] Restoring ${lastAction.state.length} images (was ${images.length})`); images = lastAction.state; // Restores the array from the deep copy consoleLog.log("Undo: Action reversed", "info"); } else if (lastAction.type === 'mask_snapshot') { // Save current complete mask state to redo const currentMaskSnapshot = new Map(); maskChunks.forEach((chunk, key) => { const tempCanvas = document.createElement('canvas'); tempCanvas.width = chunk.width; tempCanvas.height = chunk.height; const ctx = tempCanvas.getContext('2d'); ctx.drawImage(chunk, 0, 0); currentMaskSnapshot.set(key, tempCanvas); }); redoStack.push({ type: 'mask_snapshot', maskState: currentMaskSnapshot }); if (redoStack.length > MAX_HISTORY) redoStack.shift(); console.log(`[Undo] Restoring mask state with ${lastAction.maskState.size} chunks (was ${maskChunks.size})`); // Restore complete mask state maskChunks.clear(); lastAction.maskState.forEach((chunkCanvas, key) => { const newChunk = document.createElement("canvas"); newChunk.width = chunkCanvas.width; newChunk.height = chunkCanvas.height; const ctx = newChunk.getContext('2d'); ctx.drawImage(chunkCanvas, 0, 0); maskChunks.set(key, newChunk); }); consoleLog.log("Undo: Mask stroke", "info"); } else if (lastAction.type === 'mask_stroke') { // Legacy support for old mask_stroke type - convert to mask_snapshot behavior const currentMaskState = []; lastAction.stroke.forEach(item => { const chunk = maskChunks.get(item.key); if (chunk) { const tempCanvas = document.createElement('canvas'); tempCanvas.width = chunk.width; tempCanvas.height = chunk.height; tempCanvas.getContext('2d').drawImage(chunk, 0, 0); currentMaskState.push({ key: item.key, prevState: tempCanvas }); } }); if (currentMaskState.length > 0) { redoStack.push({ type: 'mask_stroke', stroke: currentMaskState }); if (redoStack.length > MAX_HISTORY) redoStack.shift(); } // Restore previous state for each chunk lastAction.stroke.forEach(item => { const chunk = maskChunks.get(item.key); if (chunk) { const ctx = chunk.getContext('2d'); ctx.clearRect(0, 0, chunk.width, chunk.height); ctx.drawImage(item.prevState, 0, 0); } else { const newChunk = document.createElement("canvas"); newChunk.width = item.prevState.width; newChunk.height = item.prevState.height; const ctx = newChunk.getContext('2d'); ctx.drawImage(item.prevState, 0, 0); maskChunks.set(item.key, newChunk); } }); consoleLog.log("Undo: Mask stroke", "info"); } redraw(); } function performRedo() { if (redoStack.length === 0 || isInferencing) return; const nextAction = redoStack.pop(); if (nextAction.type === 'canvas_snapshot') { // Deep copy current state back to undo const currentState = images.map(imgObj => { const canvasCopy = document.createElement('canvas'); canvasCopy.width = imgObj.img.width || imgObj.w; canvasCopy.height = imgObj.img.height || imgObj.h; const ctx = canvasCopy.getContext('2d'); ctx.drawImage(imgObj.img, 0, 0); return { img: canvasCopy, x: imgObj.x, y: imgObj.y, w: imgObj.w, h: imgObj.h }; }); historyStack.push({ type: 'canvas_snapshot', state: currentState }); if (historyStack.length > MAX_HISTORY) historyStack.shift(); console.log(`[Redo] Restoring ${nextAction.state.length} images`); images = nextAction.state; consoleLog.log("Redo: Action restored", "info"); } else if (nextAction.type === 'mask_snapshot') { // Save current complete mask state back to undo const currentMaskSnapshot = new Map(); maskChunks.forEach((chunk, key) => { const tempCanvas = document.createElement('canvas'); tempCanvas.width = chunk.width; tempCanvas.height = chunk.height; const ctx = tempCanvas.getContext('2d'); ctx.drawImage(chunk, 0, 0); currentMaskSnapshot.set(key, tempCanvas); }); historyStack.push({ type: 'mask_snapshot', maskState: currentMaskSnapshot }); if (historyStack.length > MAX_HISTORY) historyStack.shift(); console.log(`[Redo] Restoring mask state with ${nextAction.maskState.size} chunks`); // Restore complete mask state maskChunks.clear(); nextAction.maskState.forEach((chunkCanvas, key) => { const newChunk = document.createElement("canvas"); newChunk.width = chunkCanvas.width; newChunk.height = chunkCanvas.height; const ctx = newChunk.getContext('2d'); ctx.drawImage(chunkCanvas, 0, 0); maskChunks.set(key, newChunk); }); consoleLog.log("Redo: Mask stroke", "info"); } else if (nextAction.type === 'mask_stroke') { // Legacy support for old mask_stroke type const currentMaskState = []; nextAction.stroke.forEach(item => { const chunk = maskChunks.get(item.key); if (chunk) { const tempCanvas = document.createElement('canvas'); tempCanvas.width = chunk.width; tempCanvas.height = chunk.height; tempCanvas.getContext('2d').drawImage(chunk, 0, 0); currentMaskState.push({ key: item.key, prevState: tempCanvas }); } }); if (currentMaskState.length > 0) { historyStack.push({ type: 'mask_stroke', stroke: currentMaskState }); if (historyStack.length > MAX_HISTORY) historyStack.shift(); } // Restore redo state for each chunk nextAction.stroke.forEach(item => { const chunk = maskChunks.get(item.key); if (chunk) { const ctx = chunk.getContext('2d'); ctx.clearRect(0, 0, chunk.width, chunk.height); ctx.drawImage(item.prevState, 0, 0); } else { const newChunk = document.createElement("canvas"); newChunk.width = item.prevState.width; newChunk.height = item.prevState.height; const ctx = newChunk.getContext('2d'); ctx.drawImage(item.prevState, 0, 0); maskChunks.set(item.key, newChunk); } }); consoleLog.log("Redo: Mask stroke", "info"); } redraw(); } // ---------- Console Logger ---------- const consoleLog = { container: null, init() { this.container = document.getElementById('console-logger'); if (!this.container) { this.container = document.createElement('div'); this.container.id = 'console-logger'; document.body.appendChild(this.container); } // Apply container layout (no background here) Object.assign(this.container.style, { position: "fixed", bottom: "20px", right: "20px", zIndex: "10000", display: "flex", flexDirection: "column", alignItems: "flex-end", pointerEvents: "none", background: "transparent" }); }, log(msg, type = 'info') { if (!this.container) this.init(); const entry = document.createElement('div'); // NEW CLASS NAME TO AVOID CSS CONFLICTS entry.className = `canvas-status-message log-${type}`; // Styling the individual log message Object.assign(entry.style, { padding: "8px 12px", borderRadius: "6px", color: type === 'error' ? '#ff4444' : '#00d2ff', // Cyan text for info marginBottom: "5px", fontSize: "12px", fontWeight: "bold", // THE FIX: Set background to transparent backgroundColor: "transparent", border: "none", boxShadow: "none", // Add shadow to text so it's readable over images textShadow: "1px 1px 2px rgba(0,0,0,0.8)", marginTop: '6px', backgroundColor: 'transparent', animation: 'fadeInOut 6s ease-in-out forwards' }); entry.textContent = `> ${msg}`; this.container.appendChild(entry); // Auto-remove after 6 seconds setTimeout(() => entry.remove(), 6000); } }; // ---------- Glow/Shine Flash Effect ---------- function triggerGlowFlash(worldX, worldY, width, height) { const overlay = document.getElementById('glow-overlay'); if (!overlay) return; // Convert world coordinates to screen coordinates const screenPos = worldToScreen(worldX, worldY); const scaledWidth = width * zoom; const scaledHeight = height * zoom; // Position and size the overlay to match the inference tile Object.assign(overlay.style, { left: `${screenPos.x}px`, top: `${screenPos.y}px`, width: `${scaledWidth}px`, height: `${scaledHeight}px` }); // Remove active class if it exists (to restart animation) overlay.classList.remove('active'); // Force reflow to restart animation void overlay.offsetWidth; // Add active class to trigger animation overlay.classList.add('active'); glowAnimationActive = true; // Remove class and flag after animation completes setTimeout(() => { overlay.classList.remove('active'); glowAnimationActive = false; redraw(); // Redraw to show tile selector again }, 1800); } // ---------- Particle Effects ---------- function createParticles(worldX, worldY, count = 20) { const colors = ['#00d2ff', '#4ecdc4', '#45b7d1', '#85c1e2']; for (let i = 0; i < count; i++) { const particle = document.createElement('div'); const screenPos = worldToScreen(worldX, worldY); const angle = (Math.PI * 2 * i) / count + Math.random() * 0.5; const distance = Math.random() * 100 + 50; const endX = screenPos.x + Math.cos(angle) * distance; const endY = screenPos.y + Math.sin(angle) * distance; const color = colors[Math.floor(Math.random() * colors.length)]; const size = Math.random() * 6 + 4; const duration = Math.random() * 0.5 + 0.8; Object.assign(particle.style, { position: 'fixed', left: `${screenPos.x}px`, top: `${screenPos.y}px`, width: `${size}px`, height: `${size}px`, background: color, borderRadius: '50%', pointerEvents: 'none', zIndex: '10001', boxShadow: `0 0 ${size * 2}px ${color}`, transition: `all ${duration}s cubic-bezier(0.25, 0.46, 0.45, 0.94)`, opacity: '1' }); document.body.appendChild(particle); requestAnimationFrame(() => { Object.assign(particle.style, { left: `${endX}px`, top: `${endY}px`, opacity: '0', transform: 'scale(0)' }); }); setTimeout(() => particle.remove(), duration * 1000 + 100); } } // ---------- Image Fade-In Effect ---------- let fadeInImages = new Set(); function addImageWithFade(imgData) { fadeInImages.add(imgData); imgData.fadeAlpha = 0; imgData.fadeStartTime = Date.now(); } const style = document.createElement('style'); style.textContent = ` .inference-status { position: absolute; bottom: 10px; left: 10px; right: 10px; background: rgba(0, 0, 0, 0.8); border-radius: 4px; padding: 8px; text-align: center; font-size: 12px; font-weight: bold; color: #feca57; text-shadow: 0 1px 2px rgba(0,0,0,0.8); animation: pulse 2s ease-in-out infinite; } .inference-progress-container { position: absolute; bottom: 40px; left: 10px; right: 10px; height: 8px; background: rgba(0, 0, 0, 0.6); border-radius: 4px; overflow: hidden; border: 1px solid rgba(255, 159, 67, 0.3); } .inference-progress-bar { height: 100%; background: linear-gradient(90deg, #ff9f43, #feca57); width: 0%; transition: width 0.3s ease; box-shadow: 0 0 10px rgba(255, 159, 67, 0.5); } @keyframes pulse { 0%, 100% { opacity: 0.8; } 50% { opacity: 1; } } .progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: bold; color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.8); pointer-events: none; } /* 1. THE INFERENCE TILE FRAME */ .inference-tile { position: fixed; border: 1px solid rgba(255, 159, 67, 0.2); background: rgba(255, 159, 67, 0.03); pointer-events: none; z-index: 1001; animation: orangePulse 2s infinite ease-in-out; } @keyframes orangePulse { 0%, 100% { box-shadow: 0 0 4px rgba(255, 159, 67, 0.4), 0 0 12px rgba(255, 159, 67, 0.2); border-color: rgba(255, 159, 67, 0.3); } 50% { box-shadow: 0 0 8px rgba(255, 159, 67, 0.8), 0 0 25px rgba(255, 159, 67, 0.4); border-color: rgba(255, 159, 67, 0.8); } } .inference-abort { position: absolute; top: 10px; right: 10px; background: #e74c3c; color: white; border: none; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 16px; display: flex; align-items: center; justify-content: center; pointer-events: auto; transition: all 0.2s; } /* 2. FLOATING CONSOLE LOGS (No Background, No Bars) */ #console-log { position: fixed; bottom: 20px; left: 20px; z-index: 1000; pointer-events: none; display: flex; flex-direction: column; gap: 4px; /* Tight gap between messages */ } .log-entry { font-family: 'monospace'; font-size: 14px; font-weight: bold; background: none !important; border: none !important; /* Handle long text without scrollbars */ white-space: pre-wrap; /* Wraps text to next line if too long */ word-wrap: break-word; /* Breaks long words if necessary */ text-align: right; /* Keeps text pinned to the right edge */ width: 100%; /* Ensures text uses the container width */ text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000, 0px 2px 4px rgba(0,0,0,0.8); } .log-info { color: #4a9eff; } .log-success { color: #2ecc71; } .log-error { color: #e74c3c; } .log-warning { color: #f39c12; } @keyframes fadeInOut { 0% { opacity: 0; transform: translateX(20px); } 10% { opacity: 1; transform: translateX(0); } 85% { opacity: 1; } 100% { opacity: 0; transform: translateY(-20px); } } /* Add this to your style.textContent */ #drag-preview { position: fixed; pointer-events: none; /* Crucial so it doesn't block the drop event */ border: 3px dashed #ff9f43; background: rgba(255, 159, 67, 0.15); z-index: 10000; display: none; box-shadow: 0 0 20px rgba(0,0,0,0.5); } `; document.head.appendChild(style); // ---------- Configuration ---------- let TILE_WIDTH = 512; let TILE_HEIGHT = 768; const CHUNK_SIZE = 1024; const GRID_SIZE = 32; let maskVisible = true; let maskOpacity = 0.3; let brushSize = 200; let featherSize = 20; let mode = "paint"; // ---------- Infinite State ---------- let zoom = window.innerWidth / 4096; let offsetX = window.innerWidth / 2; let offsetY = window.innerHeight / 2; let images = []; let maskChunks = new Map(); let painting = false; let erasing = false; let panning = false; let lastPos = null; let mouseWorld = { x: 0, y: 0 }; let panStart = { x: 0, y: 0 }; let touchedChunks = new Set(); let isInferencing = false; let currentPromptId = null; let inferenceTimeout = null; // Safety timeout let lastProgressTime = null; // Track when we last received progress let tileErasing = false; let lastErasedTile = null; let glowAnimationActive = false; // Track if glow animation is playing let brushHidden = false; // Hide brush during and after generation let brushHideTimeout = null; // Timeout for showing brush again // Image placement state let placementMode = false; let placementImage = null; // {img, width, height} // Helper function to show brush after 2 second delay function showBrushAfterDelay() { if (brushHideTimeout) clearTimeout(brushHideTimeout); brushHideTimeout = setTimeout(() => { brushHidden = false; redraw(); }, 2000); } // Copy entire flattened canvas to clipboard with glow animation async function copyToClipboard() { if (isInferencing) return; try { if (images.length === 0) { consoleLog.log("No images to copy", "warning"); return; } // Calculate bounds of all images let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; images.forEach(img => { minX = Math.min(minX, img.x); minY = Math.min(minY, img.y); maxX = Math.max(maxX, img.x + img.w); maxY = Math.max(maxY, img.y + img.h); }); const copyW = maxX - minX; const copyH = maxY - minY; // Create canvas with full image bounds const copyCanvas = document.createElement("canvas"); copyCanvas.width = copyW; copyCanvas.height = copyH; const ctx = copyCanvas.getContext("2d"); // Draw all images ctx.translate(-minX, -minY); images.forEach(img => ctx.drawImage(img.img, img.x, img.y, img.w, img.h)); // Convert to blob and copy to clipboard copyCanvas.toBlob(async (blob) => { try { await navigator.clipboard.write([ new ClipboardItem({ 'image/png': blob }) ]); // Trigger orange burning glow animation covering entire image triggerGlowFlash(minX, minY, copyW, copyH); consoleLog.log(`Copied entire canvas ${copyW}×${copyH}px to clipboard`, "success"); } catch (err) { consoleLog.log(`Clipboard error: ${err.message}`, "error"); } }, "image/png"); } catch (err) { consoleLog.log(`Copy failed: ${err.message}`, "error"); } } // Initialize WebSocket connection function connectWebSocket() { if (ws && ws.readyState === WebSocket.OPEN) return; try { ws = new WebSocket(`ws://${COMFY_HOST}/ws?clientId=${getClientId()}`); ws.onopen = () => { console.log("✅ WebSocket connected to ComfyUI"); wsReconnectAttempts = 0; }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); handleWebSocketMessage(msg); }; ws.onerror = (error) => { console.error("❌ WebSocket error:", error); }; ws.onclose = () => { console.log("🔌 WebSocket disconnected"); if (wsReconnectAttempts < WS_MAX_RECONNECT_ATTEMPTS) { wsReconnectAttempts++; setTimeout(() => connectWebSocket(), 2000 * wsReconnectAttempts); } }; } catch (error) { console.error("Failed to create WebSocket:", error); } } function getClientId() { let clientId = localStorage.getItem('comfy_client_id'); if (!clientId) { clientId = 'infinity_canvas_' + Math.random().toString(36).substr(2, 9); localStorage.setItem('comfy_client_id', clientId); } return clientId; } function handleWebSocketMessage(msg) { const { type, data } = msg; // Debug logging console.log(`[WS] Message type: ${type}`, data); if (type === 'executing') { // data.node is the currently executing node, or null when done console.log(`[WS] Executing - node: ${data.node}, prompt_id: ${data.prompt_id}, current: ${currentPromptId}`); // Check if this is our prompt OR if we have a pending inference and node is null const isOurPrompt = data.prompt_id === currentPromptId; const isCompletionSignal = data.node === null && pendingInference && isInferencing; if (isOurPrompt || isCompletionSignal) { if (data.node === null) { // Execution complete console.log(`[WS] Execution complete. receivedProgress=${receivedProgressThisInference}`); clearInferenceTimeout(); // If we received progress messages, this was a real execution - fetch results if (receivedProgressThisInference) { console.log("[WS] Real execution completed, fetching results..."); updateInferenceStatus("Finalizing..."); if (pendingInference) { setTimeout(() => { fetchExecutionResult(currentPromptId, pendingInference.tx, pendingInference.ty); }, 300); } } else { // No progress = cached execution, use local cache console.log("[WS] Cached execution (no progress), checking for cached result..."); if (lastInferenceResult && pendingInference) { const hasLoadImageNode = lastInferenceResult.hasLoadImageNode !== undefined ? lastInferenceResult.hasLoadImageNode : true; consoleLog.log("Workflow unchanged - using cached result", "info"); processInferenceResult(lastInferenceResult.imgInfo, pendingInference.tx, pendingInference.ty, hasLoadImageNode); } else { // No client-side cache available consoleLog.log("No previous result available - change workflow first", "warning"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); } } } } } else if (type === 'progress') { if (data.prompt_id === currentPromptId) { receivedProgressThisInference = true; // Mark that we got progress lastProgressTime = Date.now(); const percent = Math.round((data.value / data.max) * 100); updateInferenceStatus(`Generating... ${percent}%`, percent); // Reset safety timeout on each progress update resetInferenceTimeout(); // If we hit 100%, start a fallback fetch after a short delay if (percent >= 100 && pendingInference) { console.log("[WS] Progress at 100%, starting fallback timer..."); setTimeout(() => { // Only fetch if we're still inferencing (not already handled by executed/executing) if (isInferencing && pendingInference && currentPromptId) { console.log("[WS] Fallback: fetching results after 100% progress"); updateInferenceStatus("Finalizing...", 100); fetchExecutionResult(currentPromptId, pendingInference.tx, pendingInference.ty); } }, 1500); } } } else if (type === 'executed') { // Node execution complete with output - THIS is the most reliable completion signal console.log(`[WS] Node executed with output:`, data); clearInferenceTimeout(); // Clear safety timeout if (data.prompt_id === currentPromptId && data.output?.images && data.output.images.length > 0) { // Images are ready - use them directly! console.log("[WS] Images ready in executed message, processing directly"); const imgInfo = data.output.images[0]; if (pendingInference) { const hasLoadImageNode = window.hasLoadImageNode !== undefined ? window.hasLoadImageNode : true; console.log(`[WS Executed] window.hasLoadImageNode=${window.hasLoadImageNode}, using hasLoadImageNode=${hasLoadImageNode}`); lastInferenceResult = { imgInfo, tx: pendingInference.tx, ty: pendingInference.ty, hasLoadImageNode }; processInferenceResult(imgInfo, pendingInference.tx, pendingInference.ty, hasLoadImageNode); } } } else if (type === 'execution_error') { if (data.prompt_id === currentPromptId) { consoleLog.log(`Execution error: ${data.exception_message || 'Unknown error'}`, "error"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); } } else if (type === 'execution_cached') { console.log("[WS] Execution cached - workflow unchanged"); if (data.prompt_id === currentPromptId) { clearInferenceTimeout(); console.log(`[WS Cached] lastInferenceResult exists: ${!!lastInferenceResult}, pendingInference exists: ${!!pendingInference}`); // Workflow unchanged - use cached result immediately if available if (lastInferenceResult && pendingInference) { const hasLoadImageNode = lastInferenceResult.hasLoadImageNode !== undefined ? lastInferenceResult.hasLoadImageNode : true; console.log(`[WS Cached] Using cached result with hasLoadImageNode=${hasLoadImageNode}`); consoleLog.log("Workflow unchanged - using cached result", "info"); processInferenceResult(lastInferenceResult.imgInfo, pendingInference.tx, pendingInference.ty, hasLoadImageNode); } else { // No cached result available - notify user and cancel console.log(`[WS Cached] No cached result available`); consoleLog.log("No previous result available - change workflow first", "warning"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); } } } } async function fetchExecutionResult(promptId, tx, ty, retryCount = 0) { const maxRetries = 5; console.log(`[Fetch] Attempting to fetch result for ${promptId}, retry ${retryCount}/${maxRetries}`); try { updateInferenceStatus("Loading..."); const histRes = await fetch(`http://${COMFY_HOST}/history/${promptId}`); const histData = await histRes.json(); console.log(`[Fetch] History data:`, histData); if (histData[promptId]) { const outputs = histData[promptId].outputs; const status = histData[promptId].status; console.log(`[Fetch] Status:`, status, `Outputs:`, outputs); if (outputs && Object.keys(outputs).length > 0) { let imgInfo = null; for (const nodeId in outputs) { if (outputs[nodeId].images && outputs[nodeId].images.length > 0) { imgInfo = outputs[nodeId].images[0]; console.log(`[Fetch] Found image in node ${nodeId}:`, imgInfo); break; } } if (imgInfo) { const hasLoadImageNode = window.hasLoadImageNode !== undefined ? window.hasLoadImageNode : true; console.log(`[Fetch] window.hasLoadImageNode=${window.hasLoadImageNode}, using hasLoadImageNode=${hasLoadImageNode}`); lastInferenceResult = { imgInfo, tx, ty, hasLoadImageNode }; processInferenceResult(imgInfo, tx, ty, hasLoadImageNode); return; } } // No outputs yet - check if execution is complete (status.completed exists) if (status && status.completed === true) { // Execution completed but no output - workflow unchanged, use cache immediately console.log("[Fetch] Execution complete but no output - workflow likely cached/unchanged"); if (lastInferenceResult) { consoleLog.log("Workflow unchanged - using cached result", "info"); processInferenceResult(lastInferenceResult.imgInfo, tx, ty, lastInferenceResult.hasLoadImageNode); return; } else { // No cached result available consoleLog.log("No previous result available - change workflow first", "warning"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); return; } } // No outputs yet, but history exists - retry if under limit if (retryCount < maxRetries) { console.log(`[Fetch] No outputs yet, retrying (${retryCount + 1}/${maxRetries})...`); setTimeout(() => { fetchExecutionResult(promptId, tx, ty, retryCount + 1); }, 500); return; } // Max retries reached - use cached result if available console.log("[Fetch] Max retries reached, checking cache"); if (lastInferenceResult) { consoleLog.log("No new output - using cached result", "warning"); processInferenceResult(lastInferenceResult.imgInfo, tx, ty, lastInferenceResult.hasLoadImageNode); } else { consoleLog.log("No output generated", "error"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); } } else { // No history data yet - retry if under limit if (retryCount < maxRetries) { console.log(`[Fetch] History not ready, retrying (${retryCount + 1}/${maxRetries})...`); setTimeout(() => { fetchExecutionResult(promptId, tx, ty, retryCount + 1); }, 500); return; } // Max retries reached console.log("[Fetch] No history after max retries"); if (lastInferenceResult) { consoleLog.log("History timeout - using cached result", "warning"); processInferenceResult(lastInferenceResult.imgInfo, lastInferenceResult.tx, lastInferenceResult.ty, lastInferenceResult.hasLoadImageNode); } else { consoleLog.log("Failed to fetch results", "error"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); } } } catch (error) { console.error("[Fetch] Error:", error); // Retry on error if under limit if (retryCount < maxRetries) { console.log(`[Fetch] Error, retrying (${retryCount + 1}/${maxRetries})...`); setTimeout(() => { fetchExecutionResult(promptId, tx, ty, retryCount + 1); }, 500); return; } // Max retries reached console.log("[Fetch] Max retries after error"); if (lastInferenceResult) { consoleLog.log("Error fetching result - using cache", "warning"); processInferenceResult(lastInferenceResult.imgInfo, lastInferenceResult.tx, lastInferenceResult.ty, lastInferenceResult.hasLoadImageNode); } else { consoleLog.log("Failed to fetch results", "error"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); } } } const bufferCanvas = document.createElement("canvas"); const bufferCtx = bufferCanvas.getContext("2d"); const brushSlider = document.getElementById('brushSlider'); const brushContainer = document.getElementById('brushContainer'); const featherInput = document.getElementById('featherInput'); if (brushSlider) { brushSlider.oninput = function () { brushSize = parseInt(this.value); updateBrushSizeDisplay(); }; } // Global Ctrl+Wheel handler to change brush size instantly regardless of pointer location window.addEventListener('wheel', (e) => { if (!e.ctrlKey) return; e.preventDefault(); // Use fixed 25-integer steps per user request const step = 25; const delta = e.deltaY < 0 ? step : -step; brushSize = Math.max(10, Math.min(2000, Math.round(brushSize + delta))); if (brushSlider) brushSlider.value = brushSize; updateBrushSizeDisplay(); redraw(); }, { passive: false }); // Update the live brush size display function updateBrushSizeDisplay() { const display = document.getElementById('brushSizeDisplay'); if (display) { display.textContent = brushSize; } } // Initialize display updateBrushSizeDisplay(); if (featherInput) { featherInput.oninput = function () { featherSize = Math.max(0, parseInt(this.value) || 0); }; // Ensure the UI shows the default feather value try { featherInput.value = featherSize; featherInput.dispatchEvent(new Event('input')); } catch (err) { console.warn('Failed to set default feather input:', err); } } // ---------- Setup & Resize ---------- function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; bufferCanvas.width = window.innerWidth; bufferCanvas.height = window.innerHeight; redraw(); } window.onresize = debounce(resize, 150); resize(); // Initialize WebSocket connection connectWebSocket(); function getChunk(cx, cy, createIfMissing = false) { const key = `${cx},${cy}`; if (maskChunks.has(key)) return maskChunks.get(key); if (createIfMissing) { const c = document.createElement("canvas"); c.width = CHUNK_SIZE; c.height = CHUNK_SIZE; maskChunks.set(key, c); return c; } return null; } // ---------- Workflow Listener ---------- // Try to restore workflow from localStorage on load try { const savedWorkflow = localStorage.getItem('infinity_canvas_workflow'); if (savedWorkflow) { window.activeWorkflow = JSON.parse(savedWorkflow); consoleLog.log("Workflow restored from previous session", "success"); } } catch (err) { console.warn('Failed to restore workflow:', err); } window.addEventListener("message", (event) => { if (event.data && event.data.type === "SYNC_WORKFLOW") { window.activeWorkflow = event.data.workflow; // Save workflow to localStorage for persistence across refreshes try { localStorage.setItem('infinity_canvas_workflow', JSON.stringify(event.data.workflow)); } catch (err) { console.warn('Failed to save workflow to localStorage:', err); } consoleLog.log("Workflow synced successfully", "success"); if (window.pingTimer) { clearInterval(window.pingTimer); window.pingTimer = null; } } }); window.pingTimer = setInterval(() => { if (window.activeWorkflow) { clearInterval(window.pingTimer); window.pingTimer = null; } else if (window.opener) { // Silent ping - no console log window.opener.postMessage("CANVAS_READY", "*"); } else { clearInterval(window.pingTimer); } }, 2000); // ---------- Drag & Drop ---------- canvas.ondragover = e => e.preventDefault(); // ---------- Flattening ---------- function flattenImages() { if (images.length <= 1) return; consoleLog.log("Flattening images...", "info"); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; images.forEach(img => { minX = Math.min(minX, img.x); minY = Math.min(minY, img.y); maxX = Math.max(maxX, img.x + img.w); maxY = Math.max(maxY, img.y + img.h); }); const flatCanvas = document.createElement("canvas"); flatCanvas.width = maxX - minX; flatCanvas.height = maxY - minY; const flatCtx = flatCanvas.getContext("2d"); images.forEach(img => { flatCtx.drawImage(img.img, img.x - minX, img.y - minY, img.w, img.h); }); images = [{ img: flatCanvas, x: minX, y: minY, w: flatCanvas.width, h: flatCanvas.height }]; consoleLog.log("Images flattened", "success"); redraw(); } // ---------- Input Logic ---------- canvas.addEventListener("mousedown", e => { const { x, y } = screenToWorld(e.clientX, e.clientY); // Handle placement mode click if (placementMode && e.button === 0) { // Snap to grid - center the image on the grid const snapX = Math.round((x - placementImage.width / 2) / GRID_SIZE) * GRID_SIZE; const snapY = Math.round((y - placementImage.height / 2) / GRID_SIZE) * GRID_SIZE; // Place the image at snapped position saveUndoState(); const newImg = { img: placementImage.img, x: snapX, y: snapY, w: placementImage.width, h: placementImage.height }; images.push(newImg); addImageWithFade(newImg); // Trigger particle effect at placement location createParticles(snapX + placementImage.width / 2, snapY + placementImage.height / 2, 15); consoleLog.log(`Image placed: ${placementImage.width}x${placementImage.height}`, "success"); placementMode = false; placementImage = null; canvas.style.cursor = mode === 'paint' ? 'crosshair' : 'default'; flattenImages(); redraw(); return; } if (e.button === 1 || mode === 'pan') { panning = true; panStart = { x: e.clientX, y: e.clientY }; canvas.style.cursor = "grabbing"; return; } if (isInferencing) return; if (mode === "paint") { if (e.button === 0) { painting = true; erasing = false; // Add painting class for visual feedback const brushGroup = document.getElementById("brushGroup"); if (brushGroup) brushGroup.classList.add("painting"); } if (e.button === 2) { e.preventDefault(); erasing = true; painting = false; // Add erasing class for visual feedback const brushGroup = document.getElementById("brushGroup"); if (brushGroup) brushGroup.classList.add("erasing"); } // Save complete mask state before starting stroke saveMaskState(); lastPos = { x, y }; touchedChunks.clear(); drawStroke(x, y); } if (mode === "tile") { const tx = Math.round((x - TILE_WIDTH / 2) / GRID_SIZE) * GRID_SIZE; const ty = Math.round((y - TILE_HEIGHT / 2) / GRID_SIZE) * GRID_SIZE; if (e.button === 0) { flattenImages(); saveTileAsPNG(tx, ty); } else if (e.button === 2) { e.preventDefault(); // Start continuous tile erasing while right button is held tileErasing = true; lastErasedTile = { tx, ty }; // Save undo state once at start of continuous erase saveUndoState(); deleteImagesAtTile(tx, ty, true, true); // Skip undo and log since we just saved } } }); canvas.addEventListener("mousemove", e => { mouseWorld = screenToWorld(e.clientX, e.clientY); if (panning) { offsetX += e.clientX - panStart.x; offsetY += e.clientY - panStart.y; panStart = { x: e.clientX, y: e.clientY }; } else if (painting || erasing) { drawStroke(mouseWorld.x, mouseWorld.y); } else if (tileErasing && mode === "tile") { // Continuous tile erasing while right mouse is held const tx = Math.round((mouseWorld.x - TILE_WIDTH / 2) / GRID_SIZE) * GRID_SIZE; const ty = Math.round((mouseWorld.y - TILE_HEIGHT / 2) / GRID_SIZE) * GRID_SIZE; if (!lastErasedTile || lastErasedTile.tx !== tx || lastErasedTile.ty !== ty) { lastErasedTile = { tx, ty }; deleteImagesAtTile(tx, ty, true, true); // Skip undo and log - already saved at start } } // Always redraw if in placement mode to update preview position if (placementMode || panning || painting || erasing) { redraw(); } else { redraw(); } }); // Removed global wheel handler for ctrl+scroll to avoid duplicate handling // Brush-size via Ctrl+Scroll is handled on the canvas element for realtime updates. window.addEventListener("mouseup", () => { if (painting || erasing) { // Clear stroke tracking but don't save to history (already saved on mousedown) currentStroke = []; touchedChunks.clear(); // Remove painting/erasing classes const brushGroup = document.getElementById("brushGroup"); if (brushGroup) { brushGroup.classList.remove("painting", "erasing"); } } // Log tile erase only once on mouseup if (tileErasing) { consoleLog.log("Tile area erased", "info"); } painting = false; erasing = false; panning = false; // Stop tile erasing on mouseup tileErasing = false; lastErasedTile = null; // Don't change cursor if in placement mode if (!placementMode) { if (mode === 'pan') { canvas.style.cursor = "grab"; } else { canvas.style.cursor = "crosshair"; } } }); canvas.addEventListener("wheel", e => { // REMOVED: if (isInferencing) return; e.preventDefault(); if (e.ctrlKey) { // Ctrl+wheel handled globally to keep behavior consistent; skip here e.preventDefault(); return; } else { const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; const mBefore = screenToWorld(e.clientX, e.clientY); zoom = clamp(zoom * zoomFactor, 0.05, 5); const mAfter = screenToWorld(e.clientX, e.clientY); offsetX += (mAfter.x - mBefore.x) * zoom; offsetY += (mAfter.y - mBefore.y) * zoom; } redraw(); }, { passive: false }); // ---------- Drawing ---------- function drawStroke(x, y) { if (!lastPos) return; const dx = x - lastPos.x, dy = y - lastPos.y; const dist = Math.hypot(dx, dy); const steps = Math.max(1, Math.ceil(dist / (brushSize / 4))); for (let i = 0; i <= steps; i++) { const t = i / steps; const px = lastPos.x + dx * t, py = lastPos.y + dy * t; const rad = brushSize / 2; const sCX = Math.floor((px - rad) / CHUNK_SIZE), eCX = Math.floor((px + rad) / CHUNK_SIZE); const sCY = Math.floor((py - rad) / CHUNK_SIZE), eCY = Math.floor((py + rad) / CHUNK_SIZE); for (let cy = sCY; cy <= eCY; cy++) { for (let cx = sCX; cx <= eCX; cx++) { const key = `${cx},${cy}`; const chunk = getChunk(cx, cy, true); if (!touchedChunks.has(key)) { const tempCanvas = document.createElement('canvas'); tempCanvas.width = CHUNK_SIZE; tempCanvas.height = CHUNK_SIZE; tempCanvas.getContext('2d').drawImage(chunk, 0, 0); currentStroke.push({ key: key, prevState: tempCanvas }); touchedChunks.add(key); } const cCtx = chunk.getContext("2d"); cCtx.globalCompositeOperation = erasing ? "destination-out" : "source-over"; // Apply feathering using radial gradient if (featherSize > 0) { const clampedFeather = Math.min(featherSize, rad); // Ensure feather doesn't exceed radius const innerRadius = Math.max(0, rad - clampedFeather); const gradient = cCtx.createRadialGradient( px - cx * CHUNK_SIZE, py - cy * CHUNK_SIZE, innerRadius, px - cx * CHUNK_SIZE, py - cy * CHUNK_SIZE, rad ); gradient.addColorStop(0, "rgba(255, 255, 255, 1)"); gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); cCtx.fillStyle = gradient; } else { cCtx.fillStyle = "white"; } cCtx.beginPath(); cCtx.arc(px - cx * CHUNK_SIZE, py - cy * CHUNK_SIZE, rad, 0, Math.PI * 2); cCtx.fill(); } } } lastPos = { x, y }; } // ---------- Rendering ---------- function redraw() { // 1. Clear and Prepare Main Canvas ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = "#222"; ctx.fillRect(0, 0, canvas.width, canvas.height); // 2. Clear Buffer Canvas bufferCtx.setTransform(1, 0, 0, 1, 0, 0); bufferCtx.clearRect(0, 0, bufferCanvas.width, bufferCanvas.height); // 3. Apply Camera Transform (Zoom/Pan) to both contexts ctx.setTransform(zoom, 0, 0, zoom, offsetX, offsetY); bufferCtx.setTransform(zoom, 0, 0, zoom, offsetX, offsetY); // 4. Draw Background Grid const sC = Math.floor(-offsetX / zoom / GRID_SIZE), eC = sC + (canvas.width / zoom / GRID_SIZE) + 1; const sR = Math.floor(-offsetY / zoom / GRID_SIZE), eR = sR + (canvas.height / zoom / GRID_SIZE) + 1; for (let y = sR; y < eR; y++) { for (let x = sC; x < eC; x++) { ctx.fillStyle = ((x + y) % 2) ? "#333" : "#2a2a2a"; ctx.fillRect(x * GRID_SIZE, y * GRID_SIZE, GRID_SIZE, GRID_SIZE); } } // 5. Draw All Images onto Buffer for (const img of images) { // Handle fade-in animation if (fadeInImages.has(img)) { const elapsed = Date.now() - img.fadeStartTime; const fadeInDuration = 800; // ms if (elapsed < fadeInDuration) { img.fadeAlpha = Math.min(1, elapsed / fadeInDuration); bufferCtx.save(); bufferCtx.globalAlpha = img.fadeAlpha; bufferCtx.drawImage(img.img, img.x, img.y, img.w, img.h); bufferCtx.restore(); // Request another frame to continue animation requestAnimationFrame(() => redraw()); } else { // Animation complete fadeInImages.delete(img); img.fadeAlpha = 1; bufferCtx.drawImage(img.img, img.x, img.y, img.w, img.h); } } else { bufferCtx.drawImage(img.img, img.x, img.y, img.w, img.h); } } // 6. Draw Mask Chunks (if visible) onto Buffer if (maskVisible && !brushHidden) { bufferCtx.save(); bufferCtx.globalAlpha = maskOpacity; maskChunks.forEach((chunk, key) => { const [cx, cy] = key.split(',').map(Number); bufferCtx.drawImage(chunk, cx * CHUNK_SIZE, cy * CHUNK_SIZE); }); bufferCtx.restore(); } // 7. Composite Buffer onto Main Canvas ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(bufferCanvas, 0, 0); // 8. Draw UI Overlays (Tile Selector / Brush) ctx.setTransform(zoom, 0, 0, zoom, offsetX, offsetY); if (mode === "tile" && !isInferencing && !glowAnimationActive) { const tx = Math.round((mouseWorld.x - TILE_WIDTH / 2) / GRID_SIZE) * GRID_SIZE; const ty = Math.round((mouseWorld.y - TILE_HEIGHT / 2) / GRID_SIZE) * GRID_SIZE; ctx.strokeStyle = "#53B0C2"; ctx.lineWidth = 1 / zoom; ctx.strokeRect(tx, ty, TILE_WIDTH, TILE_HEIGHT); } if (mode === "paint" && !isInferencing && !brushHidden) { // Use accent brush color from CSS variables if available let accent = '#762C41'; try { const cssAccent = getComputedStyle(document.documentElement).getPropertyValue('--accent-brush'); if (cssAccent) accent = cssAccent.trim(); } catch (err) {} ctx.strokeStyle = accent; ctx.lineWidth = Math.max(1 / zoom, 1); ctx.beginPath(); // Ensure radius uses world units; keep visible for all sizes ctx.arc(mouseWorld.x, mouseWorld.y, brushSize / 2, 0, Math.PI * 2); ctx.stroke(); } // 8.5. Draw Placement Mode Preview if (placementMode && placementImage) { ctx.save(); ctx.globalAlpha = 0.7; // Snap preview to grid const imgX = Math.round((mouseWorld.x - placementImage.width / 2) / GRID_SIZE) * GRID_SIZE; const imgY = Math.round((mouseWorld.y - placementImage.height / 2) / GRID_SIZE) * GRID_SIZE; // Debug: Log snapping (uncomment to debug) // console.log(`Mouse: ${mouseWorld.x.toFixed(0)}, ${mouseWorld.y.toFixed(0)} -> Snapped: ${imgX}, ${imgY}`); ctx.drawImage(placementImage.img, imgX, imgY, placementImage.width, placementImage.height); // Draw border around placement preview ctx.globalAlpha = 1; ctx.strokeStyle = "#00d2ff"; ctx.lineWidth = 3 / zoom; ctx.strokeRect(imgX, imgY, placementImage.width, placementImage.height); ctx.restore(); } // 9. DYNAMIC INFERENCE TILE POSITIONING // This part ensures the orange frame moves and zooms with the canvas const activeTile = document.getElementById('active-inference-tile'); if (activeTile) { const tx = parseFloat(activeTile.dataset.tx); const ty = parseFloat(activeTile.dataset.ty); const tw = parseFloat(activeTile.dataset.tw) || TILE_WIDTH; const th = parseFloat(activeTile.dataset.th) || TILE_HEIGHT; // Convert the world coordinates of the tile to screen coordinates const screenPos = worldToScreen(tx, ty); const scaledWidth = tw * zoom; const scaledHeight = th * zoom; // Update the CSS live so it stays "stuck" to the world Object.assign(activeTile.style, { left: `${screenPos.x}px`, top: `${screenPos.y}px`, width: `${scaledWidth}px`, height: `${scaledHeight}px` }); } } // ---------- Inference Overlay ---------- function createInferenceTile(tx, ty, tw, th) { const tile = document.createElement('div'); tile.className = 'inference-tile'; tile.id = 'active-inference-tile'; tile.dataset.tx = tx; tile.dataset.ty = ty; tile.dataset.tw = tw || TILE_WIDTH; tile.dataset.th = th || TILE_HEIGHT; const abortBtn = document.createElement('button'); abortBtn.className = 'inference-abort'; abortBtn.textContent = '✕'; abortBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); abortInference(); }; // Progress bar container const progressContainer = document.createElement('div'); progressContainer.className = 'inference-progress-container'; progressContainer.id = 'inference-progress-container'; const progressBar = document.createElement('div'); progressBar.className = 'inference-progress-bar'; progressBar.id = 'inference-progress-bar'; progressBar.style.width = '0%'; progressContainer.appendChild(progressBar); // Status text const statusText = document.createElement('div'); statusText.className = 'inference-status'; statusText.id = 'inference-status-text'; statusText.textContent = 'Generating...'; tile.appendChild(abortBtn); tile.appendChild(progressContainer); tile.appendChild(statusText); document.body.appendChild(tile); return tile; } function updateInferenceStatus(text, percent = null) { const statusText = document.getElementById('inference-status-text'); if (statusText) { statusText.textContent = text; } const progressBar = document.getElementById('inference-progress-bar'); if (progressBar && percent !== null) { progressBar.style.width = `${percent}%`; } } function updateProgress(percentage, text) { const progressBar = document.getElementById('inference-progress-bar'); const progressText = document.getElementById('inference-progress-text'); if (progressBar && progressText) { progressBar.style.width = `${percentage}%`; progressText.textContent = text || `${Math.round(percentage)}%`; } } function removeInferenceTile() { const tile = document.getElementById('active-inference-tile'); if (tile) tile.remove(); clearInferenceTimeout(); // Always clear timeout when removing tile } // Safety timeout functions to prevent stuck inference function clearInferenceTimeout() { if (inferenceTimeout) { clearTimeout(inferenceTimeout); inferenceTimeout = null; } } function resetInferenceTimeout() { clearInferenceTimeout(); // Timeout removed - tile will wait endlessly for inference completion } function abortInference() { clearInferenceTimeout(); // Clear timeout on abort if (currentPromptId) { fetch(`http://${COMFY_HOST}/interrupt`, { method: 'POST' }) .then(() => { consoleLog.log("Inference aborted", "warning"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); }) .catch(err => { consoleLog.log(`Abort failed: ${err.message}`, "error"); }); } } // ---------- UI Handlers ---------- const modeBtn = document.getElementById("modeBtn"); modeBtn.onclick = () => { if (isInferencing) return; mode = (mode === "paint") ? "tile" : "paint"; updateModeUI(); redraw(); }; document.getElementById("saveBtn").onclick = () => { if (isInferencing) return; flattenImages(); const temp = document.createElement("canvas"); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; images.forEach(img => { minX = Math.min(minX, img.x); minY = Math.min(minY, img.y); maxX = Math.max(maxX, img.x + img.w); maxY = Math.max(maxY, img.y + img.h); }); if (images.length === 0) return; temp.width = maxX - minX; temp.height = maxY - minY; const tCtx = temp.getContext("2d"); tCtx.translate(-minX, -minY); images.forEach(img => tCtx.drawImage(img.img, img.x, img.y, img.w, img.h)); const link = document.createElement("a"); link.download = "InfinityCanvas.png"; link.href = temp.toDataURL(); link.click(); consoleLog.log("Canvas saved", "success"); }; const toggleMaskBtn = document.getElementById('toggleMaskBtn'); if (toggleMaskBtn) { toggleMaskBtn.onclick = () => { maskVisible = !maskVisible; toggleMaskBtn.textContent = maskVisible ? "👁️" : "🚫"; redraw(); }; } // ---------- Tile Save & ComfyUI ---------- async function saveTileAsPNG(tx, ty) { if (isInferencing) return; isInferencing = true; // Hide brush during generation brushHidden = true; if (brushHideTimeout) { clearTimeout(brushHideTimeout); brushHideTimeout = null; } const tileWidth = TILE_WIDTH; const tileHeight = TILE_HEIGHT; createInferenceTile(tx, ty, tileWidth, tileHeight); updateInferenceStatus("Uploading..."); // SIMPLE STATUS redraw(); const temp = document.createElement("canvas"); temp.width = tileWidth; temp.height = tileHeight; const tCtx = temp.getContext("2d"); tCtx.translate(-tx, -ty); images.forEach(img => tCtx.drawImage(img.img, img.x, img.y, img.w, img.h)); tCtx.globalCompositeOperation = "destination-out"; maskChunks.forEach((chunk, key) => { const [cx, cy] = key.split(',').map(Number); tCtx.drawImage(chunk, cx * CHUNK_SIZE, cy * CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE); }); temp.toBlob(async blob => { try { const fd = new FormData(); const filename = `infinity_${Date.now()}.png`; fd.append("image", blob, filename); consoleLog.log("Uploading tile to ComfyUI...", "info"); const up = await fetch(`http://${COMFY_HOST}/upload/image`, { method: "POST", body: fd }); if (!up.ok) throw new Error(`Upload failed: ${up.status}`); const uploadResult = await up.json(); const serverFilename = uploadResult.name; if (!window.activeWorkflow) throw new Error("No workflow synced from ComfyUI"); updateInferenceStatus("Queuing..."); // SIMPLE STATUS // 4. Inject filename into the SPECIFIC LoadImage node titled 'input_tile' let workflow = JSON.parse(JSON.stringify(window.activeWorkflow)); let tileNodeFound = false; console.log(`[SaveTile] Workflow nodes:`, Object.keys(workflow).length); for (let nodeId in workflow) { const node = workflow[nodeId]; if (node.class_type === "LoadImage") { console.log(`[SaveTile] Found LoadImage node ${nodeId}, title="${node._meta?.title}"`); } if (node.class_type === "LoadImage" && node._meta?.title === "input_tile") { node.inputs.image = serverFilename; tileNodeFound = true; console.log(`[SaveTile] Injected into input_tile node ${nodeId}`); break; } } // Fallback: If 'input_tile' isn't found, find the first available LoadImage if (!tileNodeFound) { consoleLog.log("Node 'input_tile' not found, searching for any LoadImage node.", "warning"); console.log(`[SaveTile] Searching for ANY LoadImage node...`); for (let nodeId in workflow) { if (workflow[nodeId].class_type === "LoadImage") { workflow[nodeId].inputs.image = serverFilename; tileNodeFound = true; console.log(`[SaveTile] Found LoadImage node ${nodeId}, injecting into it`); break; } } } if (!tileNodeFound) { consoleLog.log("No LoadImage node found - running workflow without tile injection.", "warning"); console.log(`[SaveTile] NO LoadImage nodes found in workflow`); } // Store whether LoadImage node was found for later use window.hasLoadImageNode = tileNodeFound; console.log(`[SaveTile] Setting window.hasLoadImageNode = ${tileNodeFound}`); // 5. Inject custom prompt if provided const promptInput = document.getElementById('promptInput'); const customPrompt = promptInput ? promptInput.value.trim() : ''; if (customPrompt) { console.log(`[SaveTile] Custom prompt provided: "${customPrompt}"`); let promptNodeFound = false; // First try to find node titled 'input_text' for (let nodeId in workflow) { const node = workflow[nodeId]; if ((node.class_type === "CLIPTextEncode" || node.class_type.includes("TextEncode")) && node._meta?.title === "input_text") { if (node.inputs && node.inputs.text !== undefined) { node.inputs.text = customPrompt; promptNodeFound = true; console.log(`[SaveTile] Injected prompt into input_text node ${nodeId}`); consoleLog.log(`Using custom prompt: "${customPrompt.substring(0, 50)}..."`, "info"); break; } } } // Fallback: Find text encoder connected to positive input of a sampler if (!promptNodeFound) { console.log(`[SaveTile] No input_text node found, searching for sampler connection...`); // Find all sampler nodes const samplerNodes = []; for (let nodeId in workflow) { const node = workflow[nodeId]; if (node.class_type && ( node.class_type.includes("Sampler") || node.class_type === "KSampler" || node.class_type === "KSamplerAdvanced" )) { samplerNodes.push({ id: nodeId, node: node }); } } // For each sampler, trace back to find text encoder on positive input for (const { id: samplerId, node: samplerNode } of samplerNodes) { if (samplerNode.inputs && samplerNode.inputs.positive) { const positiveInput = samplerNode.inputs.positive; // Check if it's a node connection [nodeId, outputIndex] if (Array.isArray(positiveInput) && positiveInput.length >= 1) { const connectedNodeId = positiveInput[0]; const connectedNode = workflow[connectedNodeId]; if (connectedNode && ( connectedNode.class_type === "CLIPTextEncode" || connectedNode.class_type.includes("TextEncode") )) { if (connectedNode.inputs && connectedNode.inputs.text !== undefined) { connectedNode.inputs.text = customPrompt; promptNodeFound = true; console.log(`[SaveTile] Injected prompt into connected text encoder ${connectedNodeId}`); consoleLog.log(`Using custom prompt: "${customPrompt.substring(0, 50)}..."`, "info"); break; } } } } } } if (!promptNodeFound) { consoleLog.log("Warning: No suitable text encoder node found for prompt override", "warning"); console.log(`[SaveTile] Could not find text encoder node for prompt injection`); } } updateInferenceStatus("Generating..."); // SIMPLE STATUS const response = await fetch(`http://${COMFY_HOST}/prompt`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: workflow }) }); const result = await response.json(); if (!response.ok) { // Extract detailed error information from ComfyUI let errorMessage = `Prompt failed: ${response.status}`; if (result.error) { errorMessage = result.error; if (result.node_errors) { const nodeErrors = Object.entries(result.node_errors) .map(([nodeId, error]) => `Node ${nodeId}: ${error.class_type} - ${error.errors?.[0]?.message || JSON.stringify(error)}`) .join('\n'); errorMessage += '\n' + nodeErrors; } } throw new Error(errorMessage); } if (result.prompt_id) { currentPromptId = result.prompt_id; pendingInference = { tx, ty }; receivedProgressThisInference = false; // Reset progress flag for new inference lastProgressTime = Date.now(); resetInferenceTimeout(); // Start safety timeout consoleLog.log(`Inference queued: ${result.prompt_id}`, "success"); // WebSocket will handle progress updates } } catch (err) { console.error("Full error:", err); consoleLog.log(`Error: ${err.message}`, "error"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); } }, "image/png"); } function processInferenceResult(imgInfo, tx, ty, hasLoadImageNode = true) { const activeTile = document.getElementById('active-inference-tile'); const tileWidth = activeTile ? parseFloat(activeTile.dataset.tw) : TILE_WIDTH; const tileHeight = activeTile ? parseFloat(activeTile.dataset.th) : TILE_HEIGHT; console.log(`[Process] hasLoadImageNode=${hasLoadImageNode}, tileWidth=${tileWidth}, tileHeight=${tileHeight}`); const url = `http://${COMFY_HOST}/view?filename=${imgInfo.filename}&type=${imgInfo.type}&subfolder=${imgInfo.subfolder || ''}&t=${Date.now()}`; const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { console.log(`[Process] Image loaded: ${img.naturalWidth}x${img.naturalHeight}`); // If no LoadImage node was found, paste at true size (not scaled) if (!hasLoadImageNode) { const imgWidth = img.naturalWidth; const imgHeight = img.naturalHeight; // Create canvas at the image's true size const resultCanvas = document.createElement("canvas"); resultCanvas.width = imgWidth; resultCanvas.height = imgHeight; const rCtx = resultCanvas.getContext("2d"); // Draw the image at its true size rCtx.drawImage(img, 0, 0); saveUndoState(); const newImg = { img: resultCanvas, x: tx, y: ty, w: imgWidth, h: imgHeight }; images.push(newImg); addImageWithFade(newImg); // Trigger effects triggerGlowFlash(tx, ty, imgWidth, imgHeight); createParticles(tx + imgWidth / 2, ty + imgHeight / 2); consoleLog.log(`Inference complete: Image pasted at true size (${imgWidth}x${imgHeight})`, "success"); } else { // Original behavior: use mask-based composition const resultCanvas = document.createElement("canvas"); resultCanvas.width = tileWidth; resultCanvas.height = tileHeight; const rCtx = resultCanvas.getContext("2d"); const maskCanvas = document.createElement("canvas"); maskCanvas.width = tileWidth; maskCanvas.height = tileHeight; const mCtx = maskCanvas.getContext("2d"); maskChunks.forEach((chunk, key) => { const [cx, cy] = key.split(',').map(Number); mCtx.drawImage(chunk, cx * CHUNK_SIZE - tx, cy * CHUNK_SIZE - ty); }); const existingImgCanvas = document.createElement('canvas'); existingImgCanvas.width = tileWidth; existingImgCanvas.height = tileHeight; const eiCtx = existingImgCanvas.getContext('2d'); images.forEach(imgObj => { eiCtx.drawImage(imgObj.img, imgObj.x - tx, imgObj.y - ty, imgObj.w, imgObj.h); }); mCtx.globalCompositeOperation = 'destination-over'; const holeCanvas = document.createElement('canvas'); holeCanvas.width = tileWidth; holeCanvas.height = tileHeight; const hCtx = holeCanvas.getContext('2d'); hCtx.fillStyle = "white"; hCtx.fillRect(0, 0, tileWidth, tileHeight); hCtx.globalCompositeOperation = 'destination-out'; hCtx.drawImage(existingImgCanvas, 0, 0); mCtx.drawImage(holeCanvas, 0, 0); rCtx.drawImage(maskCanvas, 0, 0); rCtx.globalCompositeOperation = "source-in"; rCtx.drawImage(img, 0, 0, tileWidth, tileHeight); saveUndoState(); const newImg = { img: resultCanvas, x: tx, y: ty, w: tileWidth, h: tileHeight }; images.push(newImg); addImageWithFade(newImg); // Trigger effects triggerGlowFlash(tx, ty, tileWidth, tileHeight); createParticles(tx + tileWidth / 2, ty + tileHeight / 2); consoleLog.log("Inference complete: Image pasted.", "success"); } isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); flattenImages(); showBrushAfterDelay(); redraw(); }; img.onerror = () => { consoleLog.log("Failed to load generated image", "error"); isInferencing = false; currentPromptId = null; pendingInference = null; removeInferenceTile(); showBrushAfterDelay(); redraw(); }; img.src = url; } function deleteImagesAtTile(tx, ty, skipUndo = false, skipLog = false) { if (!skipUndo) saveUndoState(); const tileW = TILE_WIDTH; const tileH = TILE_HEIGHT; images.forEach(imgObj => { if (!(imgObj.img instanceof HTMLCanvasElement)) { const offscreen = document.createElement("canvas"); offscreen.width = imgObj.img.naturalWidth || imgObj.w; offscreen.height = imgObj.img.naturalHeight || imgObj.h; const offCtx = offscreen.getContext("2d"); offCtx.drawImage(imgObj.img, 0, 0); imgObj.img = offscreen; } const xOverlap = Math.max(imgObj.x, tx); const yOverlap = Math.max(imgObj.y, ty); const wOverlap = Math.min(imgObj.x + imgObj.w, tx + tileW) - xOverlap; const hOverlap = Math.min(imgObj.y + imgObj.h, ty + tileH) - yOverlap; if (wOverlap > 0 && hOverlap > 0) { const ictx = imgObj.img.getContext("2d"); const localX = xOverlap - imgObj.x; const localY = yOverlap - imgObj.y; ictx.globalCompositeOperation = "destination-out"; ictx.fillRect(localX, localY, wOverlap, hOverlap); ictx.globalCompositeOperation = "source-over"; } }); if (!skipLog) { consoleLog.log("Tile area erased", "info"); } redraw(); } function updateModeUI() { const modeBtn = document.getElementById("modeBtn"); const brushGroup = document.getElementById("brushGroup"); const tileSizeGroup = document.getElementById("tileSizeGroup"); // Remove all mode classes first if (modeBtn) { modeBtn.classList.remove("brush-mode", "tile-mode", "pan-mode"); } if (mode === "paint") { modeBtn.textContent = "🖌️ BRUSH MODE"; if (modeBtn) modeBtn.classList.add("brush-mode"); if (brushGroup) brushGroup.style.display = "flex"; if (tileSizeGroup) tileSizeGroup.style.display = "none"; canvas.style.cursor = "crosshair"; } else if (mode === "tile") { modeBtn.textContent = "🎯 TILE MODE"; if (modeBtn) modeBtn.classList.add("tile-mode"); if (brushGroup) brushGroup.style.display = "none"; if (tileSizeGroup) tileSizeGroup.style.display = "flex"; canvas.style.cursor = "crosshair"; } else if (mode === "pan") { if (modeBtn) modeBtn.classList.add("pan-mode"); canvas.style.cursor = "grab"; } } const sizeSelector = document.getElementById("sizeSelector"); if (sizeSelector) { sizeSelector.onchange = (e) => { const size = parseInt(e.target.value); TILE_WIDTH = size; TILE_HEIGHT = size; consoleLog.log(`Tile size: ${size}×${size}px`, "info"); redraw(); }; } // Custom size checkbox const customSizeCheckbox = document.getElementById("customSizeCheckbox"); const customWidth = document.getElementById("customWidth"); const customHeight = document.getElementById("customHeight"); if (customSizeCheckbox) { customSizeCheckbox.onchange = (e) => { const isCustom = e.target.checked; // Toggle disabled state and visual styling if (sizeSelector) { sizeSelector.disabled = isCustom; sizeSelector.style.opacity = isCustom ? "0.5" : "1"; } if (customWidth) { customWidth.disabled = !isCustom; customWidth.style.opacity = isCustom ? "1" : "0.5"; } if (customHeight) { customHeight.disabled = !isCustom; customHeight.style.opacity = isCustom ? "1" : "0.5"; } // Update tile dimensions if (isCustom) { TILE_WIDTH = parseInt(customWidth.value) || 512; TILE_HEIGHT = parseInt(customHeight.value) || 768; consoleLog.log(`Custom tile size: ${TILE_WIDTH}×${TILE_HEIGHT}px`, "info"); } else { const size = parseInt(sizeSelector.value); TILE_WIDTH = size; TILE_HEIGHT = size; consoleLog.log(`Tile size: ${size}×${size}px`, "info"); } redraw(); }; // Make custom size selected by default on load try { customSizeCheckbox.checked = true; customSizeCheckbox.dispatchEvent(new Event('change')); } catch (err) { console.warn('Failed to set default custom size checkbox:', err); } } // Custom size inputs if (customWidth) { customWidth.oninput = (e) => { const width = parseInt(e.target.value) || 512; TILE_WIDTH = Math.max(64, Math.min(4096, width)); consoleLog.log(`Tile width: ${TILE_WIDTH}px`, "info"); redraw(); }; } if (customHeight) { customHeight.oninput = (e) => { const height = parseInt(e.target.value) || 768; TILE_HEIGHT = Math.max(64, Math.min(4096, height)); consoleLog.log(`Tile height: ${TILE_HEIGHT}px`, "info"); redraw(); }; } // ---------- Keyboard Handlers ---------- // Clipboard paste support - paste images from Photoshop, browsers, screenshots, etc. window.addEventListener("paste", e => { if (isInferencing) return; const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith("image/")) { e.preventDefault(); const blob = item.getAsFile(); if (!blob) continue; const reader = new FileReader(); reader.onload = (event) => { const img = new Image(); img.onload = () => { // Convert to canvas for consistency const imgCanvas = document.createElement("canvas"); imgCanvas.width = img.naturalWidth; imgCanvas.height = img.naturalHeight; const imgCtx = imgCanvas.getContext("2d"); imgCtx.drawImage(img, 0, 0); // Enter placement mode placementMode = true; placementImage = { img: imgCanvas, width: img.naturalWidth, height: img.naturalHeight }; canvas.style.cursor = "copy"; consoleLog.log(`Image pasted (${img.naturalWidth}×${img.naturalHeight}) - click to place`, "success"); redraw(); }; img.src = event.target.result; }; reader.readAsDataURL(blob); return; // Only handle first image } } }); window.addEventListener("keydown", e => { if (isInferencing && e.key.toLowerCase() !== 'escape') return; const key = e.key.toLowerCase(); // Check if user is typing in an input field const activeElement = document.activeElement; const isTyping = activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA'); // Cancel placement mode with ESC if (key === 'escape' && placementMode) { placementMode = false; placementImage = null; canvas.style.cursor = mode === 'paint' ? 'crosshair' : 'default'; consoleLog.log("Placement cancelled", "info"); redraw(); return; } // Redo if ((e.ctrlKey || e.metaKey) && e.shiftKey && key === 'z') { e.preventDefault(); performRedo(); return; } // Undo if ((e.ctrlKey || e.metaKey) && key === 'z' && !e.shiftKey) { e.preventDefault(); performUndo(); return; } // Copy to clipboard (Ctrl+C) if ((e.ctrlKey || e.metaKey) && key === 'c') { e.preventDefault(); copyToClipboard(); return; } // Toggle mask visibility - skip if typing if (key === 'z' && !e.ctrlKey && !e.metaKey && !isTyping) { maskVisible = !maskVisible; const toggleBtn = document.getElementById('toggleMaskBtn'); if (toggleBtn) { toggleBtn.textContent = maskVisible ? "👁️" : "🚫"; } consoleLog.log(`Mask ${maskVisible ? 'visible' : 'hidden'}`, "info"); redraw(); } // Toggle toolbar visibility (hide all except info button) - skip if typing if (key === 'h' && !e.ctrlKey && !e.metaKey && !e.shiftKey && !isTyping) { const buttonRow = document.querySelector('.button-row'); if (buttonRow) { buttonRow.classList.toggle('toolbar-hidden'); const isHidden = buttonRow.classList.contains('toolbar-hidden'); consoleLog.log(`Toolbar ${isHidden ? 'hidden' : 'visible'}`, "info"); } } // Toggle mode - skip if typing if (key === 'x' && !isTyping) { if (isInferencing) return; mode = (mode === "paint") ? "tile" : "paint"; updateModeUI(); consoleLog.log(`Mode: ${mode}`, "info"); redraw(); } // Abort inference if (key === 'escape') { if (isInferencing) { abortInference(); } } // Pan mode (hold space) - allow during placement mode if (e.code === "Space" && !isInferencing && mode !== "pan" && !isTyping) { e.preventDefault(); mode = "pan"; canvas.style.cursor = "grab"; } }); window.addEventListener("keyup", e => { if (e.code === "Space" && mode === "pan") { mode = "paint"; // Restore cursor appropriately if (placementMode) { canvas.style.cursor = "crosshair"; } else { updateModeUI(); } redraw(); } }); // ---------- Helper Functions ---------- function screenToWorld(sx, sy) { return { x: (sx - offsetX) / zoom, y: (sy - offsetY) / zoom }; } function worldToScreen(wx, wy) { return { x: wx * zoom + offsetX, y: wy * zoom + offsetY }; } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } // ----- drag let dragPreview = null; function ensureDragPreview() { dragPreview = document.getElementById('drag-preview'); if (!dragPreview) { dragPreview = document.createElement('div'); dragPreview.id = 'drag-preview'; document.body.appendChild(dragPreview); } } window.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); ensureDragPreview(); // Use TILE_WIDTH for the preview box (assuming square for preview) const visualSize = TILE_WIDTH * zoom; dragPreview.style.display = 'block'; dragPreview.style.width = `${visualSize}px`; dragPreview.style.height = `${visualSize}px`; // Centers the box on your cursor dragPreview.style.left = `${e.clientX - visualSize / 2}px`; dragPreview.style.top = `${e.clientY - visualSize / 2}px`; e.dataTransfer.dropEffect = 'copy'; }); window.addEventListener('dragleave', (e) => { if (!e.relatedTarget && dragPreview) dragPreview.style.display = 'none'; }); window.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); if (dragPreview) dragPreview.style.display = 'none'; const files = e.dataTransfer.files; if (files && files.length > 0) { const file = files[0]; if (file.type.startsWith('image/')) { const reader = new FileReader(); reader.onload = (event) => { const img = new Image(); img.onload = () => { // Enter placement mode instead of immediately placing placementMode = true; placementImage = { img: img, width: img.width, height: img.height }; canvas.style.cursor = 'crosshair'; consoleLog.log(`Placement mode: ${img.width}x${img.height} - Click to place`, "info"); redraw(); }; img.src = event.target.result; }; reader.readAsDataURL(file); } } }); // ---------- Initialize ---------- DOM.init(); // Cache all DOM references consoleLog.init(); updateModeUI(); consoleLog.log("Infinity Canvas initialized", "success");