mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -03:00
feat: add local image analysis functionality and update import modal for URL/local path input. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/140
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import base64
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import torch
|
import torch
|
||||||
@@ -56,6 +57,7 @@ class RecipeRoutes:
|
|||||||
app.router.add_get('/api/recipes', routes.get_recipes)
|
app.router.add_get('/api/recipes', routes.get_recipes)
|
||||||
app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail)
|
app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail)
|
||||||
app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image)
|
app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image)
|
||||||
|
app.router.add_post('/api/recipes/analyze-local-image', routes.analyze_local_image)
|
||||||
app.router.add_post('/api/recipes/save', routes.save_recipe)
|
app.router.add_post('/api/recipes/save', routes.save_recipe)
|
||||||
app.router.add_delete('/api/recipe/{recipe_id}', routes.delete_recipe)
|
app.router.add_delete('/api/recipe/{recipe_id}', routes.delete_recipe)
|
||||||
|
|
||||||
@@ -300,7 +302,6 @@ class RecipeRoutes:
|
|||||||
|
|
||||||
# For URL mode, include the image data as base64
|
# For URL mode, include the image data as base64
|
||||||
if is_url_mode and temp_path:
|
if is_url_mode and temp_path:
|
||||||
import base64
|
|
||||||
with open(temp_path, "rb") as image_file:
|
with open(temp_path, "rb") as image_file:
|
||||||
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
@@ -317,7 +318,6 @@ class RecipeRoutes:
|
|||||||
|
|
||||||
# For URL mode, include the image data as base64
|
# For URL mode, include the image data as base64
|
||||||
if is_url_mode and temp_path:
|
if is_url_mode and temp_path:
|
||||||
import base64
|
|
||||||
with open(temp_path, "rb") as image_file:
|
with open(temp_path, "rb") as image_file:
|
||||||
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
@@ -332,7 +332,6 @@ class RecipeRoutes:
|
|||||||
|
|
||||||
# For URL mode, include the image data as base64
|
# For URL mode, include the image data as base64
|
||||||
if is_url_mode and temp_path:
|
if is_url_mode and temp_path:
|
||||||
import base64
|
|
||||||
with open(temp_path, "rb") as image_file:
|
with open(temp_path, "rb") as image_file:
|
||||||
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
@@ -356,6 +355,84 @@ class RecipeRoutes:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting temporary file: {e}")
|
logger.error(f"Error deleting temporary file: {e}")
|
||||||
|
|
||||||
|
async def analyze_local_image(self, request: web.Request) -> web.Response:
|
||||||
|
"""Analyze a local image file for recipe metadata"""
|
||||||
|
try:
|
||||||
|
# Ensure services are initialized
|
||||||
|
await self.init_services()
|
||||||
|
|
||||||
|
# Get JSON data from request
|
||||||
|
data = await request.json()
|
||||||
|
file_path = data.get('path')
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return web.json_response({
|
||||||
|
'error': 'No file path provided',
|
||||||
|
'loras': []
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Normalize file path for cross-platform compatibility
|
||||||
|
file_path = os.path.normpath(file_path.strip('"').strip("'"))
|
||||||
|
|
||||||
|
# Validate that the file exists
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
return web.json_response({
|
||||||
|
'error': 'File not found',
|
||||||
|
'loras': []
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# Extract metadata from the image using ExifUtils
|
||||||
|
metadata = ExifUtils.extract_image_metadata(file_path)
|
||||||
|
|
||||||
|
# If no metadata found, return error
|
||||||
|
if not metadata:
|
||||||
|
# Get base64 image data
|
||||||
|
with open(file_path, "rb") as image_file:
|
||||||
|
image_base64 = base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"error": "No metadata found in this image",
|
||||||
|
"loras": [], # Return empty loras array to prevent client-side errors
|
||||||
|
"image_base64": image_base64
|
||||||
|
}, status=200)
|
||||||
|
|
||||||
|
# Use the parser factory to get the appropriate parser
|
||||||
|
parser = RecipeParserFactory.create_parser(metadata)
|
||||||
|
|
||||||
|
if parser is None:
|
||||||
|
# Get base64 image data
|
||||||
|
with open(file_path, "rb") as image_file:
|
||||||
|
image_base64 = base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"error": "No parser found for this image",
|
||||||
|
"loras": [], # Return empty loras array to prevent client-side errors
|
||||||
|
"image_base64": image_base64
|
||||||
|
}, status=200)
|
||||||
|
|
||||||
|
# Parse the metadata
|
||||||
|
result = await parser.parse_metadata(
|
||||||
|
metadata,
|
||||||
|
recipe_scanner=self.recipe_scanner,
|
||||||
|
civitai_client=self.civitai_client
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add base64 image data to result
|
||||||
|
with open(file_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)
|
||||||
|
|
||||||
|
return web.json_response(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error analyzing local image: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'error': str(e),
|
||||||
|
'loras': [] # Return empty loras array to prevent client-side errors
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
"""Save a recipe to the recipes folder"""
|
"""Save a recipe to the recipes folder"""
|
||||||
@@ -425,7 +502,6 @@ class RecipeRoutes:
|
|||||||
if not image:
|
if not image:
|
||||||
if image_base64:
|
if image_base64:
|
||||||
# Convert base64 to binary
|
# Convert base64 to binary
|
||||||
import base64
|
|
||||||
try:
|
try:
|
||||||
# Remove potential data URL prefix
|
# Remove potential data URL prefix
|
||||||
if ',' in image_base64:
|
if ',' in image_base64:
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export class ImportManager {
|
|||||||
// 添加对注入样式的引用
|
// 添加对注入样式的引用
|
||||||
this.injectedStyles = null;
|
this.injectedStyles = null;
|
||||||
|
|
||||||
// Add import mode tracking
|
// Change default mode to url/path
|
||||||
this.importMode = 'upload'; // Default mode: 'upload' or 'url'
|
this.importMode = 'url'; // Default mode changed to: 'url' or 'upload'
|
||||||
}
|
}
|
||||||
|
|
||||||
showImportModal(recipeData = null, recipeId = null) {
|
showImportModal(recipeData = null, recipeId = null) {
|
||||||
@@ -123,9 +123,9 @@ export class ImportManager {
|
|||||||
this.missingLoras = [];
|
this.missingLoras = [];
|
||||||
this.downloadableLoRAs = [];
|
this.downloadableLoRAs = [];
|
||||||
|
|
||||||
// Reset import mode to upload
|
// Reset import mode to url/path instead of upload
|
||||||
this.importMode = 'upload';
|
this.importMode = 'url';
|
||||||
this.toggleImportMode('upload');
|
this.toggleImportMode('url');
|
||||||
|
|
||||||
// Clear selected folder and remove selection from UI
|
// Clear selected folder and remove selection from UI
|
||||||
this.selectedFolder = '';
|
this.selectedFolder = '';
|
||||||
@@ -224,17 +224,11 @@ export class ImportManager {
|
|||||||
async handleUrlInput() {
|
async handleUrlInput() {
|
||||||
const urlInput = document.getElementById('imageUrlInput');
|
const urlInput = document.getElementById('imageUrlInput');
|
||||||
const errorElement = document.getElementById('urlError');
|
const errorElement = document.getElementById('urlError');
|
||||||
const url = urlInput.value.trim();
|
const input = urlInput.value.trim();
|
||||||
|
|
||||||
// Validate URL
|
// Validate input
|
||||||
if (!url) {
|
if (!input) {
|
||||||
errorElement.textContent = 'Please enter a URL';
|
errorElement.textContent = 'Please enter a URL or file path';
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic URL validation
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
||||||
errorElement.textContent = 'Please enter a valid URL';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,13 +236,19 @@ export class ImportManager {
|
|||||||
errorElement.textContent = '';
|
errorElement.textContent = '';
|
||||||
|
|
||||||
// Show loading indicator
|
// Show loading indicator
|
||||||
this.loadingManager.showSimpleLoading('Fetching image from URL...');
|
this.loadingManager.showSimpleLoading('Processing input...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call API to analyze the URL
|
// Check if it's a URL or a local file path
|
||||||
await this.analyzeImageFromUrl(url);
|
if (input.startsWith('http://') || input.startsWith('https://')) {
|
||||||
|
// Handle as URL
|
||||||
|
await this.analyzeImageFromUrl(input);
|
||||||
|
} else {
|
||||||
|
// Handle as local file path
|
||||||
|
await this.analyzeImageFromLocalPath(input);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorElement.textContent = error.message || 'Failed to fetch image from URL';
|
errorElement.textContent = error.message || 'Failed to process input';
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingManager.hide();
|
this.loadingManager.hide();
|
||||||
}
|
}
|
||||||
@@ -295,6 +295,48 @@ export class ImportManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add new method to handle local file paths
|
||||||
|
async analyzeImageFromLocalPath(path) {
|
||||||
|
try {
|
||||||
|
// Call the API with local path data
|
||||||
|
const response = await fetch('/api/recipes/analyze-local-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ path: path })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Failed to load image from local path');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 local path:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async uploadAndAnalyzeImage() {
|
async uploadAndAnalyzeImage() {
|
||||||
if (!this.recipeImage) {
|
if (!this.recipeImage) {
|
||||||
showToast('Please select an image first', 'error');
|
showToast('Please select an image first', 'error');
|
||||||
|
|||||||
@@ -6,13 +6,28 @@
|
|||||||
<!-- Step 1: Upload Image or Input URL -->
|
<!-- Step 1: Upload Image or Input URL -->
|
||||||
<div class="import-step" id="uploadStep">
|
<div class="import-step" id="uploadStep">
|
||||||
<div class="import-mode-toggle">
|
<div class="import-mode-toggle">
|
||||||
<button class="toggle-btn active" data-mode="upload" onclick="importManager.toggleImportMode('upload')">
|
<button class="toggle-btn active" data-mode="url" onclick="importManager.toggleImportMode('url')">
|
||||||
|
<i class="fas fa-link"></i> URL / Local Path
|
||||||
|
</button>
|
||||||
|
<button class="toggle-btn" data-mode="upload" onclick="importManager.toggleImportMode('upload')">
|
||||||
<i class="fas fa-upload"></i> Upload Image
|
<i class="fas fa-upload"></i> Upload Image
|
||||||
</button>
|
</button>
|
||||||
<button class="toggle-btn" data-mode="url" onclick="importManager.toggleImportMode('url')">
|
</div>
|
||||||
<i class="fas fa-link"></i> Input URL
|
|
||||||
|
<!-- Input URL/Path Section -->
|
||||||
|
<div class="import-section" id="urlSection">
|
||||||
|
<p>Input a Civitai image URL or local file path to import as a recipe.</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="imageUrlInput">Image URL or File Path:</label>
|
||||||
|
<div class="input-with-button">
|
||||||
|
<input type="text" id="imageUrlInput" placeholder="https://civitai.com/images/... or C:/path/to/image.png">
|
||||||
|
<button class="primary-btn" onclick="importManager.handleUrlInput()">
|
||||||
|
<i class="fas fa-download"></i> Fetch Image
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="error-message" id="urlError"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Upload Image Section -->
|
<!-- Upload Image Section -->
|
||||||
<div class="import-section" id="uploadSection">
|
<div class="import-section" id="uploadSection">
|
||||||
@@ -29,21 +44,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input URL Section -->
|
|
||||||
<div class="import-section" id="urlSection" style="display: none;">
|
|
||||||
<p>Input a Civitai image URL to import as a recipe.</p>
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="imageUrlInput">Image URL:</label>
|
|
||||||
<div class="input-with-button">
|
|
||||||
<input type="text" id="imageUrlInput" placeholder="https://civitai.com/images/...">
|
|
||||||
<button class="primary-btn" onclick="importManager.handleUrlInput()">
|
|
||||||
<i class="fas fa-download"></i> Fetch Image
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="error-message" id="urlError"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="secondary-btn" onclick="modalManager.closeModal('importModal')">Cancel</button>
|
<button class="secondary-btn" onclick="modalManager.closeModal('importModal')">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user