Files
kreonxv-ComfyUI-Infinity-Ca…/web/script.js
T
2026-02-03 11:05:32 +02:00

2453 lines
88 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");