diff --git a/__init__.py b/__init__.py index 4adea9d1..008219c8 100644 --- a/__init__.py +++ b/__init__.py @@ -2,15 +2,17 @@ from .py.lora_manager import LoraManager from .py.nodes.lora_loader import LoraManagerLoader from .py.nodes.trigger_word_toggle import TriggerWordToggle from .py.nodes.lora_stacker import LoraStacker +from .py.nodes.save_image import SaveImage NODE_CLASS_MAPPINGS = { LoraManagerLoader.NAME: LoraManagerLoader, TriggerWordToggle.NAME: TriggerWordToggle, - LoraStacker.NAME: LoraStacker + LoraStacker.NAME: LoraStacker, + SaveImage.NAME: SaveImage } WEB_DIRECTORY = "./web/comfyui" # Register routes on import LoraManager.add_routes() -__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY'] \ No newline at end of file +__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY'] diff --git a/py/nodes/save_image.py b/py/nodes/save_image.py new file mode 100644 index 00000000..09fec5e4 --- /dev/null +++ b/py/nodes/save_image.py @@ -0,0 +1,41 @@ +import json +from server import PromptServer # type: ignore + +class SaveImage: + NAME = "Save Image (LoraManager)" + CATEGORY = "Lora Manager/utils" + DESCRIPTION = "Experimental node to display image preview and print prompt and extra_pnginfo" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO", + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "process_image" + + def process_image(self, image, prompt=None, extra_pnginfo=None): + # Print the prompt information + print("SaveImage Node - Prompt:") + if prompt: + print(json.dumps(prompt, indent=2)) + else: + print("No prompt information available") + + # Print the extra_pnginfo + print("\nSaveImage Node - Extra PNG Info:") + if extra_pnginfo: + print(json.dumps(extra_pnginfo, indent=2)) + else: + print("No extra PNG info available") + + # Return the image unchanged + return (image,) diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 09fd6e43..32dc1976 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -190,45 +190,90 @@ class RecipeRoutes: return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') async def analyze_recipe_image(self, request: web.Request) -> web.Response: - """Analyze an uploaded image for recipe metadata""" + """Analyze an uploaded image or URL for recipe metadata""" temp_path = None try: - reader = await request.multipart() - field = await reader.next() + # Check if request contains multipart data (image) or JSON data (url) + content_type = request.headers.get('Content-Type', '') - if field.name != 'image': - return web.json_response({ - "error": "No image field found", - "loras": [] - }, status=400) + is_url_mode = False - # Create a temporary file to store the uploaded image - with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file: - while True: - chunk = await field.read_chunk() - if not chunk: - break - temp_file.write(chunk) - temp_path = temp_file.name + if 'multipart/form-data' in content_type: + # Handle image upload + reader = await request.multipart() + field = await reader.next() + + if field.name != 'image': + return web.json_response({ + "error": "No image field found", + "loras": [] + }, status=400) + + # Create a temporary file to store the uploaded image + with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file: + while True: + chunk = await field.read_chunk() + if not chunk: + break + temp_file.write(chunk) + temp_path = temp_file.name + + elif 'application/json' in content_type: + # Handle URL input + data = await request.json() + url = data.get('url') + is_url_mode = True + + if not url: + return web.json_response({ + "error": "No URL provided", + "loras": [] + }, status=400) + + # Download image from URL + from ..utils.utils import download_twitter_image + temp_path = download_twitter_image(url) + + if not temp_path: + return web.json_response({ + "error": "Failed to download image from URL", + "loras": [] + }, status=400) # Extract metadata from the image using ExifUtils user_comment = ExifUtils.extract_user_comment(temp_path) # If no metadata found, return a more specific error if not user_comment: - return web.json_response({ + result = { "error": "No metadata found in this image", "loras": [] # Return empty loras array to prevent client-side errors - }, status=200) # Return 200 instead of 400 to handle gracefully + } + + # For URL mode, include the image data as base64 + if is_url_mode and temp_path: + import base64 + with open(temp_path, "rb") as image_file: + result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8') + + return web.json_response(result, status=200) # Use the parser factory to get the appropriate parser parser = RecipeParserFactory.create_parser(user_comment) if parser is None: - return web.json_response({ + result = { "error": "No parser found for this image", "loras": [] # Return empty loras array to prevent client-side errors - }, status=200) # Return 200 instead of 400 to handle gracefully + } + + # For URL mode, include the image data as base64 + if is_url_mode and temp_path: + import base64 + with open(temp_path, "rb") as image_file: + result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8') + + return web.json_response(result, status=200) # Parse the metadata result = await parser.parse_metadata( @@ -237,6 +282,12 @@ class RecipeRoutes: civitai_client=self.civitai_client ) + # For URL mode, include the image data as base64 + if is_url_mode and temp_path: + import base64 + with open(temp_path, "rb") as image_file: + result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8') + # Check for errors if "error" in result and not result.get("loras"): return web.json_response(result, status=200) @@ -265,6 +316,8 @@ class RecipeRoutes: # Process form data image = None + image_base64 = None + image_url = None name = None tags = [] metadata = None @@ -284,6 +337,14 @@ class RecipeRoutes: image_data += chunk image = image_data + elif field.name == 'image_base64': + # Get base64 image data + image_base64 = await field.text() + + elif field.name == 'image_url': + # Get image URL + image_url = await field.text() + elif field.name == 'name': name = await field.text() @@ -301,8 +362,44 @@ class RecipeRoutes: except: metadata = {} - if not image or not name or not metadata: - return web.json_response({"error": "Missing required fields"}, status=400) + missing_fields = [] + if not name: + missing_fields.append("name") + if not metadata: + missing_fields.append("metadata") + if missing_fields: + return web.json_response({"error": f"Missing required fields: {', '.join(missing_fields)}"}, status=400) + + # Handle different image sources + if not image: + if image_base64: + # Convert base64 to binary + import base64 + try: + # Remove potential data URL prefix + if ',' in image_base64: + image_base64 = image_base64.split(',', 1)[1] + image = base64.b64decode(image_base64) + except Exception as e: + return web.json_response({"error": f"Invalid base64 image data: {str(e)}"}, status=400) + elif image_url: + # Download image from URL + from ..utils.utils import download_twitter_image + temp_path = download_twitter_image(image_url) + if not temp_path: + return web.json_response({"error": "Failed to download image from URL"}, status=400) + + # Read the downloaded image + with open(temp_path, 'rb') as f: + image = f.read() + + # Clean up temp file + try: + os.unlink(temp_path) + except: + pass + else: + return web.json_response({"error": "No image data provided"}, status=400) # Create recipes directory if it doesn't exist recipes_dir = self.recipe_scanner.recipes_dir @@ -625,4 +722,4 @@ class RecipeRoutes: # Remove from dictionary del self._shared_recipes[rid] except Exception as e: - logger.error(f"Error cleaning up shared recipe {rid}: {e}") \ No newline at end of file + logger.error(f"Error cleaning up shared recipe {rid}: {e}") diff --git a/py/utils/utils.py b/py/utils/utils.py new file mode 100644 index 00000000..8825a5ec --- /dev/null +++ b/py/utils/utils.py @@ -0,0 +1,41 @@ +import requests +import tempfile +import re +from bs4 import BeautifulSoup + +def download_twitter_image(url): + """Download image from a URL containing twitter:image meta tag + + Args: + url (str): The URL to download image from + + Returns: + str: Path to downloaded temporary image file + """ + try: + # Download page content + response = requests.get(url) + response.raise_for_status() + + # Parse HTML + soup = BeautifulSoup(response.text, 'html.parser') + + # Find twitter:image meta tag + meta_tag = soup.find('meta', attrs={'property': 'twitter:image'}) + if not meta_tag: + return None + + image_url = meta_tag['content'] + + # Download image + image_response = requests.get(image_url) + image_response.raise_for_status() + + # Save to temp file + with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file: + temp_file.write(image_response.content) + return temp_file.name + + except Exception as e: + print(f"Error downloading twitter image: {e}") + return None diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css index 9764d99b..780ce3db 100644 --- a/static/css/components/import-modal.css +++ b/static/css/components/import-modal.css @@ -4,6 +4,47 @@ transition: none !important; /* Disable any transitions that might affect display */ } +/* Import Mode Toggle */ +.import-mode-toggle { + display: flex; + margin-bottom: var(--space-3); + border-radius: var(--border-radius-sm); + overflow: hidden; + border: 1px solid var(--border-color); +} + +.toggle-btn { + flex: 1; + padding: 10px 16px; + background: var(--bg-color); + color: var(--text-color); + border: none; + cursor: pointer; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: background-color 0.2s, color 0.2s; +} + +.toggle-btn:first-child { + border-right: 1px solid var(--border-color); +} + +.toggle-btn.active { + background: var(--lora-accent); + color: var(--lora-text); +} + +.toggle-btn:hover:not(.active) { + background: var(--lora-surface); +} + +.import-section { + margin-bottom: var(--space-3); +} + /* File Input Styles */ .file-input-wrapper { position: relative; @@ -364,6 +405,14 @@ color: var(--text-color); } +.input-group button { + background: var(--lora-accent); + color: var(--lora-text); + border: none; + padding: 8px 16px; + border-radius: var(--border-radius-xs); +} + .error-message { color: var(--lora-error); font-size: 0.9em; diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index f55f81c6..18ef3432 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -23,6 +23,9 @@ export class ImportManager { // 添加对注入样式的引用 this.injectedStyles = null; + + // Add import mode tracking + this.importMode = 'upload'; // Default mode: 'upload' or 'url' } showImportModal() { @@ -80,16 +83,21 @@ export class ImportManager { fileInput.value = ''; } - // Reset error message - const errorElement = document.getElementById('uploadError'); - if (errorElement) { - errorElement.textContent = ''; + // Reset URL input + const urlInput = document.getElementById('imageUrlInput'); + if (urlInput) { + urlInput.value = ''; } - // Reset preview - const previewElement = document.getElementById('imagePreview'); - if (previewElement) { - previewElement.innerHTML = '
Image preview will appear here
'; + // Reset error messages + const uploadError = document.getElementById('uploadError'); + if (uploadError) { + uploadError.textContent = ''; + } + + const urlError = document.getElementById('urlError'); + if (urlError) { + urlError.textContent = ''; } // Reset recipe name input @@ -111,6 +119,10 @@ export class ImportManager { this.recipeTags = []; this.missingLoras = []; + // Reset import mode to upload + this.importMode = 'upload'; + this.toggleImportMode('upload'); + // Clear selected folder and remove selection from UI this.selectedFolder = ''; const folderBrowser = document.getElementById('importFolderBrowser'); @@ -132,6 +144,45 @@ export class ImportManager { } } + toggleImportMode(mode) { + this.importMode = mode; + + // Update toggle buttons + const uploadBtn = document.querySelector('.toggle-btn[data-mode="upload"]'); + const urlBtn = document.querySelector('.toggle-btn[data-mode="url"]'); + + if (uploadBtn && urlBtn) { + if (mode === 'upload') { + uploadBtn.classList.add('active'); + urlBtn.classList.remove('active'); + } else { + uploadBtn.classList.remove('active'); + urlBtn.classList.add('active'); + } + } + + // Show/hide appropriate sections + const uploadSection = document.getElementById('uploadSection'); + const urlSection = document.getElementById('urlSection'); + + if (uploadSection && urlSection) { + if (mode === 'upload') { + uploadSection.style.display = 'block'; + urlSection.style.display = 'none'; + } else { + uploadSection.style.display = 'none'; + urlSection.style.display = 'block'; + } + } + + // Clear error messages + const uploadError = document.getElementById('uploadError'); + const urlError = document.getElementById('urlError'); + + if (uploadError) uploadError.textContent = ''; + if (urlError) urlError.textContent = ''; + } + handleImageUpload(event) { const file = event.target.files[0]; const errorElement = document.getElementById('uploadError'); @@ -154,6 +205,85 @@ export class ImportManager { this.uploadAndAnalyzeImage(); } + async handleUrlInput() { + const urlInput = document.getElementById('imageUrlInput'); + const errorElement = document.getElementById('urlError'); + const url = urlInput.value.trim(); + + // Validate URL + if (!url) { + errorElement.textContent = 'Please enter a URL'; + return; + } + + // Basic URL validation + if (!url.startsWith('http://') && !url.startsWith('https://')) { + errorElement.textContent = 'Please enter a valid URL'; + return; + } + + // Reset error + errorElement.textContent = ''; + + // Show loading indicator + this.loadingManager.showSimpleLoading('Fetching image from URL...'); + + try { + // Call API to analyze the URL + await this.analyzeImageFromUrl(url); + } catch (error) { + errorElement.textContent = error.message || 'Failed to fetch image from URL'; + } finally { + this.loadingManager.hide(); + } + } + + async analyzeImageFromUrl(url) { + try { + // Call the API with URL data + const response = await fetch('/api/recipes/analyze-image', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url: url }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to analyze image from URL'); + } + + // Get recipe data from response + this.recipeData = await response.json(); + + // Check if we have an error message + if (this.recipeData.error) { + throw new Error(this.recipeData.error); + } + + // Check if we have valid recipe data + if (!this.recipeData || !this.recipeData.loras || this.recipeData.loras.length === 0) { + throw new Error('No LoRA information found in this image'); + } + + // Store generation parameters if available + if (this.recipeData.gen_params) { + console.log('Generation parameters found:', this.recipeData.gen_params); + } + + // Find missing LoRAs + this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); + + // Proceed to recipe details step + this.showRecipeDetailsStep(); + + } catch (error) { + console.error('Error analyzing URL:', error); + throw error; + } + } + async uploadAndAnalyzeImage() { if (!this.recipeImage) { showToast('Please select an image first', 'error'); @@ -172,7 +302,7 @@ export class ImportManager { method: 'POST', body: formData }); - + // Get recipe data from response this.recipeData = await response.json(); @@ -256,12 +386,24 @@ export class ImportManager { // Display the uploaded image in the preview const imagePreview = document.getElementById('recipeImagePreview'); - if (imagePreview && this.recipeImage) { - const reader = new FileReader(); - reader.onload = (e) => { - imagePreview.innerHTML = `Recipe preview`; - }; - reader.readAsDataURL(this.recipeImage); + if (imagePreview) { + if (this.recipeImage) { + // For file upload mode + const reader = new FileReader(); + reader.onload = (e) => { + imagePreview.innerHTML = `Recipe preview`; + }; + reader.readAsDataURL(this.recipeImage); + } else if (this.recipeData && this.recipeData.image_base64) { + // For URL mode - use the base64 image data returned from the backend + imagePreview.innerHTML = `Recipe preview`; + } else if (this.importMode === 'url') { + // Fallback for URL mode if no base64 data + const urlInput = document.getElementById('imageUrlInput'); + if (urlInput && urlInput.value) { + imagePreview.innerHTML = `Recipe preview`; + } + } } // Update LoRA count information @@ -577,10 +719,21 @@ export class ImportManager { fileInput.value = ''; } + // Reset URL input + const urlInput = document.getElementById('imageUrlInput'); + if (urlInput) { + urlInput.value = ''; + } + // Clear any previous error messages - const errorElement = document.getElementById('uploadError'); - if (errorElement) { - errorElement.textContent = ''; + const uploadError = document.getElementById('uploadError'); + if (uploadError) { + uploadError.textContent = ''; + } + + const urlError = document.getElementById('urlError'); + if (urlError) { + urlError.textContent = ''; } } @@ -600,7 +753,26 @@ export class ImportManager { // Create form data for save request const formData = new FormData(); - formData.append('image', this.recipeImage); + + // Handle image data - either from file upload or from URL mode + if (this.recipeImage) { + // File upload mode + formData.append('image', this.recipeImage); + } else if (this.recipeData && this.recipeData.image_base64) { + // URL mode with base64 data + formData.append('image_base64', this.recipeData.image_base64); + } else if (this.importMode === 'url') { + // Fallback for URL mode - tell backend to fetch the image again + const urlInput = document.getElementById('imageUrlInput'); + if (urlInput && urlInput.value) { + formData.append('image_url', urlInput.value); + } else { + throw new Error('No image data available'); + } + } else { + throw new Error('No image data available'); + } + formData.append('name', this.recipeName); formData.append('tags', JSON.stringify(this.recipeTags)); diff --git a/templates/components/import_modal.html b/templates/components/import_modal.html index 2fdc66a8..c67ef6f1 100644 --- a/templates/components/import_modal.html +++ b/templates/components/import_modal.html @@ -3,19 +3,45 @@

Import Recipe

- +
-

Upload an image with LoRA metadata to import as a recipe.

+
+ + +
-
- -
- -
- Select Image + +
+

Upload an image with LoRA metadata to import as a recipe.

+
+ +
+ +
+ Select Image +
+
+
+
+ + +
-
\ No newline at end of file +