diff --git a/canvas_node.py b/canvas_node.py index 965a264..e431c4c 100644 --- a/canvas_node.py +++ b/canvas_node.py @@ -471,6 +471,70 @@ class CanvasNode: 'error': str(e) }, status=500) + @PromptServer.instance.routes.post("/ycnode/load_image_from_path") + async def load_image_from_path_route(request): + try: + data = await request.json() + file_path = data.get('file_path') + + if not file_path: + return web.json_response({ + 'success': False, + 'error': 'file_path is required' + }, status=400) + + log_info(f"Attempting to load image from path: {file_path}") + + # Check if file exists and is accessible + if not os.path.exists(file_path): + log_warn(f"File not found: {file_path}") + return web.json_response({ + 'success': False, + 'error': f'File not found: {file_path}' + }, status=404) + + # Check if it's an image file + valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.avif') + if not file_path.lower().endswith(valid_extensions): + return web.json_response({ + 'success': False, + 'error': f'Invalid image file extension. Supported: {valid_extensions}' + }, status=400) + + # Try to load and convert the image + try: + with Image.open(file_path) as img: + # Convert to RGB if necessary + if img.mode != 'RGB': + img = img.convert('RGB') + + # Convert to base64 + buffered = io.BytesIO() + img.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode('utf-8') + + log_info(f"Successfully loaded image from path: {file_path}") + return web.json_response({ + 'success': True, + 'image_data': f"data:image/png;base64,{img_str}", + 'width': img.width, + 'height': img.height + }) + + except Exception as img_error: + log_error(f"Error processing image file {file_path}: {str(img_error)}") + return web.json_response({ + 'success': False, + 'error': f'Error processing image file: {str(img_error)}' + }, status=500) + + except Exception as e: + log_error(f"Error in load_image_from_path_route: {str(e)}") + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) + def store_image(self, image_data): if isinstance(image_data, str) and image_data.startswith('data:image'): diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 0c4a1c2..1864b85 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -3,6 +3,7 @@ import {createModuleLogger} from "./utils/LoggerUtils.js"; import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js"; import {withErrorHandling, createValidationError} from "./ErrorHandler.js"; import {app, ComfyApp} from "../../scripts/app.js"; +import {api} from "../../scripts/api.js"; const log = createModuleLogger('CanvasLayers'); @@ -143,6 +144,7 @@ export class CanvasLayers { const clipboardItems = await navigator.clipboard.read(); for (const item of clipboardItems) { + // First, try to find actual image data const imageType = item.types.find(type => type.startsWith('image/')); if (imageType) { @@ -159,9 +161,29 @@ export class CanvasLayers { log.info("Successfully pasted image from system clipboard"); return true; } + + // If no image data found, check for text that might be a file path + const textType = item.types.find(type => type === 'text/plain'); + if (textType) { + const textBlob = await item.getType(textType); + const text = await textBlob.text(); + + if (this.isValidImagePath(text)) { + log.info("Found image file path in clipboard:", text); + try { + // Try to load the image using different methods + const success = await this.loadImageFromPath(text, addMode); + if (success) { + return true; + } + } catch (pathError) { + log.warn("Error loading image from path:", pathError); + } + } + } } - log.info("No image found in system clipboard"); + log.info("No image or valid image path found in system clipboard"); return false; } catch (error) { log.warn("System clipboard paste failed:", error); @@ -169,6 +191,386 @@ export class CanvasLayers { } } + /** + * Validates if a text string is a valid image file path + * @param {string} text - The text to validate + * @returns {boolean} - True if the text appears to be a valid image file path + */ + isValidImagePath(text) { + if (!text || typeof text !== 'string') { + return false; + } + + // Trim whitespace + text = text.trim(); + + // Check if it's empty after trimming + if (!text) { + return false; + } + + // Common image file extensions + const imageExtensions = [ + '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', + '.svg', '.tiff', '.tif', '.ico', '.avif' + ]; + + // Check if the text ends with a valid image extension (case insensitive) + const hasImageExtension = imageExtensions.some(ext => + text.toLowerCase().endsWith(ext) + ); + + if (!hasImageExtension) { + return false; + } + + // Basic path validation - should look like a file path + // Accept both Windows and Unix style paths, and URLs + const pathPatterns = [ + /^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...) + /^[\\\/]/, // Unix absolute path (/...) + /^\.{1,2}[\\\/]/, // Relative path (./... or ../...) + /^https?:\/\//, // HTTP/HTTPS URL + /^file:\/\//, // File URL + /^[^\\\/]*[\\\/]/ // Contains path separators + ]; + + const isValidPath = pathPatterns.some(pattern => pattern.test(text)) || + (!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename + + return isValidPath; + } + + /** + * Attempts to load an image from a file path using various methods + * @param {string} filePath - The file path to load + * @param {string} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async loadImageFromPath(filePath, addMode) { + // Method 1: Try direct loading for URLs + if (filePath.startsWith('http://') || filePath.startsWith('https://')) { + try { + const img = new Image(); + img.crossOrigin = 'anonymous'; + return new Promise((resolve) => { + img.onload = async () => { + log.info("Successfully loaded image from URL"); + await this.addLayerWithImage(img, {}, addMode); + resolve(true); + }; + img.onerror = () => { + log.warn("Failed to load image from URL:", filePath); + resolve(false); + }; + img.src = filePath; + }); + } catch (error) { + log.warn("Error loading image from URL:", error); + return false; + } + } + + // Method 2: Try to load via ComfyUI's view endpoint for local files + try { + log.info("Attempting to load local file via ComfyUI view endpoint"); + const success = await this.loadImageViaComfyUIView(filePath, addMode); + if (success) { + return true; + } + } catch (error) { + log.warn("ComfyUI view endpoint method failed:", error); + } + + // Method 3: Try to prompt user to select the file manually + try { + log.info("Attempting to load local file via file picker"); + const success = await this.promptUserForFile(filePath, addMode); + if (success) { + return true; + } + } catch (error) { + log.warn("File picker method failed:", error); + } + + // Method 4: Show user a helpful message about the limitation + this.showFilePathMessage(filePath); + return false; + } + + /** + * Attempts to load an image using ComfyUI's API methods + * @param {string} filePath - The file path to load + * @param {string} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async loadImageViaComfyUIView(filePath, addMode) { + try { + // First, try to get folder paths to understand ComfyUI structure + const folderPaths = await this.getComfyUIFolderPaths(); + log.debug("ComfyUI folder paths:", folderPaths); + + // Extract filename from path + const fileName = filePath.split(/[\\\/]/).pop(); + + // Method 1: Try to upload the file to ComfyUI first, then load it + const uploadSuccess = await this.uploadFileToComfyUI(filePath, addMode); + if (uploadSuccess) { + return true; + } + + // Method 2: Try different view endpoints if file might already exist in ComfyUI + const viewConfigs = [ + // Direct filename approach + { filename: fileName }, + // Full path approach + { filename: filePath }, + // Input folder approach + { filename: fileName, type: 'input' }, + // Temp folder approach + { filename: fileName, type: 'temp' }, + // Output folder approach + { filename: fileName, type: 'output' } + ]; + + for (const config of viewConfigs) { + try { + // Build query parameters + const params = new URLSearchParams(); + params.append('filename', config.filename); + if (config.type) { + params.append('type', config.type); + } + if (config.subfolder) { + params.append('subfolder', config.subfolder); + } + + const viewUrl = api.apiURL(`/view?${params.toString()}`); + log.debug("Trying ComfyUI view URL:", viewUrl); + + const img = new Image(); + const success = await new Promise((resolve) => { + img.onload = async () => { + log.info("Successfully loaded image via ComfyUI view endpoint:", viewUrl); + await this.addLayerWithImage(img, {}, addMode); + resolve(true); + }; + img.onerror = () => { + log.debug("Failed to load image via ComfyUI view endpoint:", viewUrl); + resolve(false); + }; + + // Set a timeout to avoid hanging + setTimeout(() => { + resolve(false); + }, 3000); + + img.src = viewUrl; + }); + + if (success) { + return true; + } + } catch (error) { + log.debug("Error with view config:", config, error); + continue; + } + } + + return false; + } catch (error) { + log.warn("Error in loadImageViaComfyUIView:", error); + return false; + } + } + + /** + * Gets ComfyUI folder paths using the API + * @returns {Promise} - Folder paths object + */ + async getComfyUIFolderPaths() { + try { + return await api.getFolderPaths(); + } catch (error) { + log.warn("Failed to get ComfyUI folder paths:", error); + return {}; + } + } + + /** + * Attempts to load a file via ComfyUI backend endpoint + * @param {string} filePath - The file path to load + * @param {string} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async uploadFileToComfyUI(filePath, addMode) { + try { + log.info("Attempting to load file via ComfyUI backend:", filePath); + + // Use the new backend endpoint to load image from path + const response = await api.fetchApi("/ycnode/load_image_from_path", { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + log.debug("Backend failed to load image:", errorData.error); + return false; + } + + const data = await response.json(); + + if (!data.success) { + log.debug("Backend returned error:", data.error); + return false; + } + + log.info("Successfully loaded image via ComfyUI backend:", filePath); + + // Create image from the returned base64 data + const img = new Image(); + const success = await new Promise((resolve) => { + img.onload = async () => { + log.info("Successfully loaded image from backend response"); + await this.addLayerWithImage(img, {}, addMode); + resolve(true); + }; + img.onerror = () => { + log.warn("Failed to load image from backend response"); + resolve(false); + }; + + img.src = data.image_data; + }); + + return success; + + } catch (error) { + log.debug("Error loading file via ComfyUI backend:", error); + return false; + } + } + + /** + * Prompts the user to select a file when a local path is detected + * @param {string} originalPath - The original file path from clipboard + * @param {string} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async promptUserForFile(originalPath, addMode) { + return new Promise((resolve) => { + // Create a temporary file input + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.style.display = 'none'; + + // Extract filename from path for user reference + const fileName = originalPath.split(/[\\\/]/).pop(); + + fileInput.onchange = async (event) => { + const file = event.target.files[0]; + if (file && file.type.startsWith('image/')) { + try { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = async () => { + log.info("Successfully loaded image from file picker"); + await this.addLayerWithImage(img, {}, addMode); + resolve(true); + }; + img.onerror = () => { + log.warn("Failed to load selected image"); + resolve(false); + }; + img.src = e.target.result; + }; + reader.onerror = () => { + log.warn("Failed to read selected file"); + resolve(false); + }; + reader.readAsDataURL(file); + } catch (error) { + log.warn("Error processing selected file:", error); + resolve(false); + } + } else { + log.warn("Selected file is not an image"); + resolve(false); + } + + // Clean up + document.body.removeChild(fileInput); + }; + + fileInput.oncancel = () => { + log.info("File selection cancelled by user"); + document.body.removeChild(fileInput); + resolve(false); + }; + + // Show a brief notification to the user + this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000); + + // Add to DOM and trigger click + document.body.appendChild(fileInput); + fileInput.click(); + }); + } + + /** + * Shows a message to the user about file path limitations + * @param {string} filePath - The file path that couldn't be loaded + */ + showFilePathMessage(filePath) { + const fileName = filePath.split(/[\\\/]/).pop(); + const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`; + this.showNotification(message, 5000); + log.info("Showed file path limitation message to user"); + } + + /** + * Shows a temporary notification to the user + * @param {string} message - The message to show + * @param {number} duration - Duration in milliseconds + */ + showNotification(message, duration = 3000) { + // Create notification element + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #333; + color: white; + padding: 12px 16px; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + z-index: 10001; + max-width: 300px; + font-size: 14px; + line-height: 1.4; + `; + notification.textContent = message; + + // Add to DOM + document.body.appendChild(notification); + + // Remove after duration + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, duration); + } + addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => { if (!image) { throw createValidationError("Image is required for layer creation"); @@ -325,7 +727,7 @@ export class CanvasLayers { const promises = this.canvas.selectedLayers.map(layer => { return new Promise(resolve => { const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCanvas.width = layer.image.width; tempCanvas.height = layer.image.height; @@ -353,7 +755,7 @@ export class CanvasLayers { const promises = this.canvas.selectedLayers.map(layer => { return new Promise(resolve => { const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCanvas.width = layer.image.width; tempCanvas.height = layer.image.height; @@ -378,7 +780,7 @@ export class CanvasLayers { async getLayerImageData(layer) { try { const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCanvas.width = layer.width; tempCanvas.height = layer.height; @@ -625,7 +1027,7 @@ export class CanvasLayers { const tempCanvas = document.createElement('canvas'); tempCanvas.width = this.canvas.width; tempCanvas.height = this.canvas.height; - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); @@ -665,7 +1067,7 @@ export class CanvasLayers { const tempCanvas = document.createElement('canvas'); tempCanvas.width = this.canvas.width; tempCanvas.height = this.canvas.height; - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); @@ -699,7 +1101,7 @@ export class CanvasLayers { const tempMaskCanvas = document.createElement('canvas'); tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.height = this.canvas.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d'); + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); @@ -766,7 +1168,7 @@ export class CanvasLayers { const tempCanvas = document.createElement('canvas'); tempCanvas.width = this.canvas.width; tempCanvas.height = this.canvas.height; - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); @@ -800,7 +1202,7 @@ export class CanvasLayers { const tempMaskCanvas = document.createElement('canvas'); tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.height = this.canvas.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d'); + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); @@ -907,7 +1309,7 @@ export class CanvasLayers { const tempCanvas = document.createElement('canvas'); tempCanvas.width = newWidth; tempCanvas.height = newHeight; - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCtx.translate(-minX, -minY);