feat: Add support for remote video analysis and preview for recipe imports. see #420

This commit is contained in:
Will Miao
2025-12-21 21:42:28 +08:00
parent 30fd0470de
commit 7caca0163e
6 changed files with 248 additions and 123 deletions

View File

@@ -437,6 +437,7 @@ class RecipeManagementHandler:
name=payload["name"], name=payload["name"],
tags=payload["tags"], tags=payload["tags"],
metadata=payload["metadata"], metadata=payload["metadata"],
extension=payload.get("extension"),
) )
return web.json_response(result.payload, status=result.status) return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc: except RecipeValidationError as exc:
@@ -625,6 +626,7 @@ class RecipeManagementHandler:
name: Optional[str] = None name: Optional[str] = None
tags: list[str] = [] tags: list[str] = []
metadata: Optional[Dict[str, Any]] = None metadata: Optional[Dict[str, Any]] = None
extension: Optional[str] = None
while True: while True:
field = await reader.next() field = await reader.next()
@@ -655,6 +657,8 @@ class RecipeManagementHandler:
metadata = json.loads(metadata_text) metadata = json.loads(metadata_text)
except Exception: except Exception:
metadata = {} metadata = {}
elif field.name == "extension":
extension = await field.text()
return { return {
"image_bytes": image_bytes, "image_bytes": image_bytes,
@@ -662,6 +666,7 @@ class RecipeManagementHandler:
"name": name, "name": name,
"tags": tags, "tags": tags,
"metadata": metadata, "metadata": metadata,
"extension": extension,
} }
def _parse_tags(self, tag_text: Optional[str]) -> list[str]: def _parse_tags(self, tag_text: Optional[str]) -> list[str]:

View File

@@ -13,6 +13,7 @@ import numpy as np
from PIL import Image from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint from ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import rewrite_preview_url
from .errors import ( from .errors import (
RecipeDownloadError, RecipeDownloadError,
RecipeNotFoundError, RecipeNotFoundError,
@@ -94,18 +95,39 @@ class RecipeAnalysisService:
if civitai_client is None: if civitai_client is None:
raise RecipeServiceError("Civitai client unavailable") raise RecipeServiceError("Civitai client unavailable")
temp_path = self._create_temp_path() temp_path = None
metadata: Optional[dict[str, Any]] = None metadata: Optional[dict[str, Any]] = None
is_video = False
extension = ".jpg" # Default
try: try:
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url) civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
if civitai_match: if civitai_match:
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info: if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai") raise RecipeDownloadError("Failed to fetch image information from Civitai")
image_url = image_info.get("url") image_url = image_info.get("url")
if not image_url: if not image_url:
raise RecipeDownloadError("No image URL found in Civitai response") raise RecipeDownloadError("No image URL found in Civitai response")
is_video = image_info.get("type") == "video"
# Use optimized preview URLs if possible
rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type"))
if rewritten_url:
image_url = rewritten_url
if is_video:
# Extract extension from URL
url_path = image_url.split('?')[0].split('#')[0]
extension = os.path.splitext(url_path)[1].lower() or ".mp4"
else:
extension = ".jpg"
temp_path = self._create_temp_path(suffix=extension)
await self._download_image(image_url, temp_path) await self._download_image(image_url, temp_path)
metadata = image_info.get("meta") if "meta" in image_info else None metadata = image_info.get("meta") if "meta" in image_info else None
if ( if (
isinstance(metadata, dict) isinstance(metadata, dict)
@@ -114,22 +136,31 @@ class RecipeAnalysisService:
): ):
metadata = metadata["meta"] metadata = metadata["meta"]
else: else:
# Basic extension detection for non-Civitai URLs
url_path = url.split('?')[0].split('#')[0]
extension = os.path.splitext(url_path)[1].lower()
if extension in [".mp4", ".webm"]:
is_video = True
else:
extension = ".jpg"
temp_path = self._create_temp_path(suffix=extension)
await self._download_image(url, temp_path) await self._download_image(url, temp_path)
if metadata is None: if metadata is None and not is_video:
metadata = self._exif_utils.extract_image_metadata(temp_path) metadata = self._exif_utils.extract_image_metadata(temp_path)
if not metadata:
return self._metadata_not_found_response(temp_path)
return await self._parse_metadata( return await self._parse_metadata(
metadata, metadata or {},
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
image_path=temp_path, image_path=temp_path,
include_image_base64=True, include_image_base64=True,
is_video=is_video,
extension=extension,
) )
finally: finally:
self._safe_cleanup(temp_path) if temp_path:
self._safe_cleanup(temp_path)
async def analyze_local_image( async def analyze_local_image(
self, self,
@@ -198,12 +229,16 @@ class RecipeAnalysisService:
recipe_scanner, recipe_scanner,
image_path: Optional[str], image_path: Optional[str],
include_image_base64: bool, include_image_base64: bool,
is_video: bool = False,
extension: str = ".jpg",
) -> AnalysisResult: ) -> AnalysisResult:
parser = self._recipe_parser_factory.create_parser(metadata) parser = self._recipe_parser_factory.create_parser(metadata)
if parser is None: if parser is None:
payload = {"error": "No parser found for this image", "loras": []} payload = {"error": "No parser found for this image", "loras": []}
if include_image_base64 and image_path: if include_image_base64 and image_path:
payload["image_base64"] = self._encode_file(image_path) payload["image_base64"] = self._encode_file(image_path)
payload["is_video"] = is_video
payload["extension"] = extension
return AnalysisResult(payload) return AnalysisResult(payload)
result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner) result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner)
@@ -211,6 +246,9 @@ class RecipeAnalysisService:
if include_image_base64 and image_path: if include_image_base64 and image_path:
result["image_base64"] = self._encode_file(image_path) result["image_base64"] = self._encode_file(image_path)
result["is_video"] = is_video
result["extension"] = extension
if "error" in result and not result.get("loras"): if "error" in result and not result.get("loras"):
return AnalysisResult(result) return AnalysisResult(result)
@@ -241,8 +279,8 @@ class RecipeAnalysisService:
temp_file.write(data) temp_file.write(data)
return temp_file.name return temp_file.name
def _create_temp_path(self) -> str: def _create_temp_path(self, suffix: str = ".jpg") -> str:
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file: with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
return temp_file.name return temp_file.name
def _safe_cleanup(self, path: Optional[str]) -> None: def _safe_cleanup(self, path: Optional[str]) -> None:

View File

@@ -1,7 +1,8 @@
/* Import Modal Styles */ /* Import Modal Styles */
.import-step { .import-step {
margin: var(--space-2) 0; margin: var(--space-2) 0;
transition: none !important; /* Disable any transitions that might affect display */ transition: none !important;
/* Disable any transitions that might affect display */
} }
/* Import Mode Toggle */ /* Import Mode Toggle */
@@ -107,7 +108,8 @@
justify-content: center; justify-content: center;
} }
.recipe-image img { .recipe-image img,
.recipe-preview-video {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
@@ -379,7 +381,7 @@
.recipe-details-layout { .recipe-details-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.recipe-image-container { .recipe-image-container {
height: 150px; height: 150px;
} }
@@ -512,14 +514,17 @@
/* Prevent layout shift with scrollbar */ /* Prevent layout shift with scrollbar */
.modal-content { .modal-content {
overflow-y: scroll; /* Always show scrollbar */ overflow-y: scroll;
scrollbar-gutter: stable; /* Reserve space for scrollbar */ /* Always show scrollbar */
scrollbar-gutter: stable;
/* Reserve space for scrollbar */
} }
/* For browsers that don't support scrollbar-gutter */ /* For browsers that don't support scrollbar-gutter */
@supports not (scrollbar-gutter: stable) { @supports not (scrollbar-gutter: stable) {
.modal-content { .modal-content {
padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */ padding-right: calc(var(--space-2) + var(--scrollbar-width));
/* Add extra padding for scrollbar */
} }
} }
@@ -586,7 +591,8 @@
/* Remove the old warning-message styles that were causing layout issues */ /* Remove the old warning-message styles that were causing layout issues */
.warning-message { .warning-message {
display: none; /* Hide the old style */ display: none;
/* Hide the old style */
} }
/* Update deleted badge to be more prominent */ /* Update deleted badge to be more prominent */
@@ -613,7 +619,8 @@
color: var(--lora-error); color: var(--lora-error);
font-size: 0.9em; font-size: 0.9em;
margin-top: 8px; margin-top: 8px;
min-height: 20px; /* Ensure there's always space for the error message */ min-height: 20px;
/* Ensure there's always space for the error message */
font-weight: 500; font-weight: 500;
} }
@@ -662,8 +669,15 @@
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.duplicate-warning { .duplicate-warning {
@@ -779,6 +793,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
@@ -793,9 +808,9 @@
opacity: 0.8; opacity: 0.8;
} }
.duplicate-recipe-date, .duplicate-recipe-date,
.duplicate-recipe-lora-count { .duplicate-recipe-lora-count {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }

View File

@@ -12,21 +12,21 @@ export class DownloadManager {
async saveRecipe() { async saveRecipe() {
// Check if we're in download-only mode (for existing recipe) // Check if we're in download-only mode (for existing recipe)
const isDownloadOnly = !!this.importManager.recipeId; const isDownloadOnly = !!this.importManager.recipeId;
if (!isDownloadOnly && !this.importManager.recipeName) { if (!isDownloadOnly && !this.importManager.recipeName) {
showToast('toast.recipes.enterRecipeName', {}, 'error'); showToast('toast.recipes.enterRecipeName', {}, 'error');
return; return;
} }
try { try {
// Show progress indicator // Show progress indicator
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...')); this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
// Only send the complete recipe to save if not in download-only mode // Only send the complete recipe to save if not in download-only mode
if (!isDownloadOnly) { if (!isDownloadOnly) {
// Create FormData object for saving recipe // Create FormData object for saving recipe
const formData = new FormData(); const formData = new FormData();
// Add image data - depends on import mode // Add image data - depends on import mode
if (this.importManager.recipeImage) { if (this.importManager.recipeImage) {
// Direct upload // Direct upload
@@ -45,10 +45,10 @@ export class DownloadManager {
} else { } else {
throw new Error('No image data available'); throw new Error('No image data available');
} }
formData.append('name', this.importManager.recipeName); formData.append('name', this.importManager.recipeName);
formData.append('tags', JSON.stringify(this.importManager.recipeTags)); formData.append('tags', JSON.stringify(this.importManager.recipeTags));
// Prepare complete metadata including generation parameters // Prepare complete metadata including generation parameters
const completeMetadata = { const completeMetadata = {
base_model: this.importManager.recipeData.base_model || "", base_model: this.importManager.recipeData.base_model || "",
@@ -65,7 +65,11 @@ export class DownloadManager {
if (checkpointMetadata && typeof checkpointMetadata === 'object') { if (checkpointMetadata && typeof checkpointMetadata === 'object') {
completeMetadata.checkpoint = checkpointMetadata; completeMetadata.checkpoint = checkpointMetadata;
} }
if (this.importManager.recipeData && this.importManager.recipeData.extension) {
formData.append('extension', this.importManager.recipeData.extension);
}
// Add source_path to metadata to track where the recipe was imported from // Add source_path to metadata to track where the recipe was imported from
if (this.importManager.importMode === 'url') { if (this.importManager.importMode === 'url') {
const urlInput = document.getElementById('imageUrlInput'); const urlInput = document.getElementById('imageUrlInput');
@@ -73,15 +77,15 @@ export class DownloadManager {
completeMetadata.source_path = urlInput.value; completeMetadata.source_path = urlInput.value;
} }
} }
formData.append('metadata', JSON.stringify(completeMetadata)); formData.append('metadata', JSON.stringify(completeMetadata));
// Send save request // Send save request
const response = await fetch('/api/lm/recipes/save', { const response = await fetch('/api/lm/recipes/save', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
const result = await response.json(); const result = await response.json();
if (!result.success) { if (!result.success) {
@@ -102,19 +106,19 @@ export class DownloadManager {
// Show success message // Show success message
if (isDownloadOnly) { if (isDownloadOnly) {
if (failedDownloads === 0) { if (failedDownloads === 0) {
showToast('toast.loras.downloadSuccessful', {}, 'success'); showToast('toast.loras.downloadSuccessful', {}, 'success');
} }
} else { } else {
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success'); showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
} }
// Close modal // Close modal
modalManager.closeModal('importModal'); modalManager.closeModal('importModal');
// Refresh the recipe // Refresh the recipe
window.recipeManager.loadRecipes(); window.recipeManager.loadRecipes();
} catch (error) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);
showToast('toast.recipes.processingError', { message: error.message }, 'error'); showToast('toast.recipes.processingError', { message: error.message }, 'error');
@@ -129,49 +133,49 @@ export class DownloadManager {
if (!loraRoot) { if (!loraRoot) {
throw new Error(translate('recipes.controls.import.errors.selectLoraRoot', {}, 'Please select a LoRA root directory')); throw new Error(translate('recipes.controls.import.errors.selectLoraRoot', {}, 'Please select a LoRA root directory'));
} }
// Build target path // Build target path
let targetPath = ''; let targetPath = '';
if (this.importManager.selectedFolder) { if (this.importManager.selectedFolder) {
targetPath = this.importManager.selectedFolder; targetPath = this.importManager.selectedFolder;
} }
// Generate a unique ID for this batch download // Generate a unique ID for this batch download
const batchDownloadId = Date.now().toString(); const batchDownloadId = Date.now().toString();
// Set up WebSocket for progress updates // Set up WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`); const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
// Show enhanced loading with progress details for multiple items // Show enhanced loading with progress details for multiple items
const updateProgress = this.importManager.loadingManager.showDownloadProgress( const updateProgress = this.importManager.loadingManager.showDownloadProgress(
this.importManager.downloadableLoRAs.length this.importManager.downloadableLoRAs.length
); );
let completedDownloads = 0; let completedDownloads = 0;
let failedDownloads = 0; let failedDownloads = 0;
let accessFailures = 0; let accessFailures = 0;
let currentLoraProgress = 0; let currentLoraProgress = 0;
// Set up progress tracking for current download // Set up progress tracking for current download
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// Handle download ID confirmation // Handle download ID confirmation
if (data.type === 'download_id') { if (data.type === 'download_id') {
console.log(`Connected to batch download progress with ID: ${data.download_id}`); console.log(`Connected to batch download progress with ID: ${data.download_id}`);
return; return;
} }
// Process progress updates for our current active download // Process progress updates for our current active download
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) { if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
// Update current LoRA progress // Update current LoRA progress
currentLoraProgress = data.progress; currentLoraProgress = data.progress;
// Get current LoRA name // Get current LoRA name
const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads]; const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads];
const loraName = currentLora ? currentLora.name : ''; const loraName = currentLora ? currentLora.name : '';
// Update progress display // Update progress display
const metrics = { const metrics = {
bytesDownloaded: data.bytes_downloaded, bytesDownloaded: data.bytes_downloaded,
@@ -180,7 +184,7 @@ export class DownloadManager {
}; };
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics); updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
// Add more detailed status messages based on progress // Add more detailed status messages based on progress
if (currentLoraProgress < 3) { if (currentLoraProgress < 3) {
this.importManager.loadingManager.setStatus( this.importManager.loadingManager.setStatus(
@@ -203,17 +207,17 @@ export class DownloadManager {
}; };
const useDefaultPaths = getStorageItem('use_default_path_loras', false); const useDefaultPaths = getStorageItem('use_default_path_loras', false);
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) { for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
const lora = this.importManager.downloadableLoRAs[i]; const lora = this.importManager.downloadableLoRAs[i];
// Reset current LoRA progress for new download // Reset current LoRA progress for new download
currentLoraProgress = 0; currentLoraProgress = 0;
// Initial status update for new LoRA // Initial status update for new LoRA
this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i+1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`)); this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i + 1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i + 1}/${this.importManager.downloadableLoRAs.length}`));
updateProgress(0, completedDownloads, lora.name); updateProgress(0, completedDownloads, lora.name);
try { try {
// Download the LoRA with download ID // Download the LoRA with download ID
const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel( const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel(
@@ -224,7 +228,7 @@ export class DownloadManager {
useDefaultPaths, useDefaultPaths,
batchDownloadId batchDownloadId
); );
if (!response.success) { if (!response.success) {
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`); console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
@@ -248,28 +252,28 @@ export class DownloadManager {
// Continue with next download // Continue with next download
} }
} }
// Close WebSocket // Close WebSocket
ws.close(); ws.close();
// Show appropriate completion message based on results // Show appropriate completion message based on results
if (failedDownloads === 0) { if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success'); showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else { } else {
if (accessFailures > 0) { if (accessFailures > 0) {
showToast('toast.loras.downloadPartialWithAccess', { showToast('toast.loras.downloadPartialWithAccess', {
completed: completedDownloads, completed: completedDownloads,
total: this.importManager.downloadableLoRAs.length, total: this.importManager.downloadableLoRAs.length,
accessFailures: accessFailures accessFailures: accessFailures
}, 'error'); }, 'error');
} else { } else {
showToast('toast.loras.downloadPartialSuccess', { showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads, completed: completedDownloads,
total: this.importManager.downloadableLoRAs.length total: this.importManager.downloadableLoRAs.length
}, 'error'); }, 'error');
} }
} }
return failedDownloads; return failedDownloads;
} }
} }

View File

@@ -8,10 +8,10 @@ export class RecipeDataManager {
showRecipeDetailsStep() { showRecipeDetailsStep() {
this.importManager.stepManager.showStep('detailsStep'); this.importManager.stepManager.showStep('detailsStep');
// Set default recipe name from prompt or image filename // Set default recipe name from prompt or image filename
const recipeName = document.getElementById('recipeName'); const recipeName = document.getElementById('recipeName');
// Check if we have recipe metadata from a shared recipe // Check if we have recipe metadata from a shared recipe
if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) { if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) {
// Use title from recipe metadata // Use title from recipe metadata
@@ -19,24 +19,24 @@ export class RecipeDataManager {
recipeName.value = this.importManager.recipeData.title; recipeName.value = this.importManager.recipeData.title;
this.importManager.recipeName = this.importManager.recipeData.title; this.importManager.recipeName = this.importManager.recipeData.title;
} }
// Use tags from recipe metadata // Use tags from recipe metadata
if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) { if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) {
this.importManager.recipeTags = [...this.importManager.recipeData.tags]; this.importManager.recipeTags = [...this.importManager.recipeData.tags];
this.updateTagsDisplay(); this.updateTagsDisplay();
} }
} else if (this.importManager.recipeData && } else if (this.importManager.recipeData &&
this.importManager.recipeData.gen_params && this.importManager.recipeData.gen_params &&
this.importManager.recipeData.gen_params.prompt) { this.importManager.recipeData.gen_params.prompt) {
// Use the first 10 words from the prompt as the default recipe name // Use the first 10 words from the prompt as the default recipe name
const promptWords = this.importManager.recipeData.gen_params.prompt.split(' '); const promptWords = this.importManager.recipeData.gen_params.prompt.split(' ');
const truncatedPrompt = promptWords.slice(0, 10).join(' '); const truncatedPrompt = promptWords.slice(0, 10).join(' ');
recipeName.value = truncatedPrompt; recipeName.value = truncatedPrompt;
this.importManager.recipeName = truncatedPrompt; this.importManager.recipeName = truncatedPrompt;
// Set up click handler to select all text for easy editing // Set up click handler to select all text for easy editing
if (!recipeName.hasSelectAllHandler) { if (!recipeName.hasSelectAllHandler) {
recipeName.addEventListener('click', function() { recipeName.addEventListener('click', function () {
this.select(); this.select();
}); });
recipeName.hasSelectAllHandler = true; recipeName.hasSelectAllHandler = true;
@@ -47,15 +47,15 @@ export class RecipeDataManager {
recipeName.value = fileName; recipeName.value = fileName;
this.importManager.recipeName = fileName; this.importManager.recipeName = fileName;
} }
// Always set up click handler for easy editing if not already set // Always set up click handler for easy editing if not already set
if (!recipeName.hasSelectAllHandler) { if (!recipeName.hasSelectAllHandler) {
recipeName.addEventListener('click', function() { recipeName.addEventListener('click', function () {
this.select(); this.select();
}); });
recipeName.hasSelectAllHandler = true; recipeName.hasSelectAllHandler = true;
} }
// Display the uploaded image in the preview // Display the uploaded image in the preview
const imagePreview = document.getElementById('recipeImagePreview'); const imagePreview = document.getElementById('recipeImagePreview');
if (imagePreview) { if (imagePreview) {
@@ -67,13 +67,24 @@ export class RecipeDataManager {
}; };
reader.readAsDataURL(this.importManager.recipeImage); reader.readAsDataURL(this.importManager.recipeImage);
} else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) { } else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) {
// For URL mode - use the base64 image data returned from the backend // For URL mode - use the base64 data returned from the backend
imagePreview.innerHTML = `<img src="data:image/jpeg;base64,${this.importManager.recipeData.image_base64}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}">`; if (this.importManager.recipeData.is_video) {
const mimeType = this.importManager.recipeData.extension === '.webm' ? 'video/webm' : 'video/mp4';
imagePreview.innerHTML = `<video src="data:${mimeType};base64,${this.importManager.recipeData.image_base64}" controls autoplay loop muted class="recipe-preview-video"></video>`;
} else {
imagePreview.innerHTML = `<img src="data:image/jpeg;base64,${this.importManager.recipeData.image_base64}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}">`;
}
} else if (this.importManager.importMode === 'url') { } else if (this.importManager.importMode === 'url') {
// Fallback for URL mode if no base64 data // Fallback for URL mode if no base64 data
const urlInput = document.getElementById('imageUrlInput'); const urlInput = document.getElementById('imageUrlInput');
if (urlInput && urlInput.value) { if (urlInput && urlInput.value) {
imagePreview.innerHTML = `<img src="${urlInput.value}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}" crossorigin="anonymous">`; const url = urlInput.value.toLowerCase();
if (url.endsWith('.mp4') || url.endsWith('.webm')) {
const mimeType = url.endsWith('.webm') ? 'video/webm' : 'video/mp4';
imagePreview.innerHTML = `<video src="${urlInput.value}" controls autoplay loop muted class="recipe-preview-video"></video>`;
} else {
imagePreview.innerHTML = `<img src="${urlInput.value}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}" crossorigin="anonymous">`;
}
} }
} }
} }
@@ -85,7 +96,7 @@ export class RecipeDataManager {
if (loraCountInfo) { if (loraCountInfo) {
loraCountInfo.textContent = translate('recipes.controls.import.loraCountInfo', { existing: existingLoras, total: totalLoras }, `(${existingLoras}/${totalLoras} in library)`); loraCountInfo.textContent = translate('recipes.controls.import.loraCountInfo', { existing: existingLoras, total: totalLoras }, `(${existingLoras}/${totalLoras} in library)`);
} }
// Display LoRAs list // Display LoRAs list
const lorasList = document.getElementById('lorasList'); const lorasList = document.getElementById('lorasList');
if (lorasList) { if (lorasList) {
@@ -94,7 +105,7 @@ export class RecipeDataManager {
const isDeleted = lora.isDeleted; const isDeleted = lora.isDeleted;
const isEarlyAccess = lora.isEarlyAccess; const isEarlyAccess = lora.isEarlyAccess;
const localPath = lora.localPath || ''; const localPath = lora.localPath || '';
// Create status badge based on LoRA status // Create status badge based on LoRA status
let statusBadge; let statusBadge;
if (isDeleted) { if (isDeleted) {
@@ -102,7 +113,7 @@ export class RecipeDataManager {
<i class="fas fa-exclamation-circle"></i> ${translate('recipes.controls.import.deletedFromCivitai', {}, 'Deleted from Civitai')} <i class="fas fa-exclamation-circle"></i> ${translate('recipes.controls.import.deletedFromCivitai', {}, 'Deleted from Civitai')}
</div>`; </div>`;
} else { } else {
statusBadge = existsLocally ? statusBadge = existsLocally ?
`<div class="local-badge"> `<div class="local-badge">
<i class="fas fa-check"></i> ${translate('recipes.controls.import.inLibrary', {}, 'In Library')} <i class="fas fa-check"></i> ${translate('recipes.controls.import.inLibrary', {}, 'In Library')}
<div class="local-path">${localPath}</div> <div class="local-path">${localPath}</div>
@@ -126,7 +137,7 @@ export class RecipeDataManager {
console.warn('Failed to format early access date', e); console.warn('Failed to format early access date', e);
} }
} }
earlyAccessBadge = `<div class="early-access-badge"> earlyAccessBadge = `<div class="early-access-badge">
<i class="fas fa-clock"></i> ${translate('recipes.controls.import.earlyAccess', {}, 'Early Access')} <i class="fas fa-clock"></i> ${translate('recipes.controls.import.earlyAccess', {}, 'Early Access')}
<div class="early-access-info">${earlyAccessInfo} ${translate('recipes.controls.import.verifyEarlyAccess', {}, 'Verify that you have purchased early access before downloading.')}</div> <div class="early-access-info">${earlyAccessInfo} ${translate('recipes.controls.import.verifyEarlyAccess', {}, 'Verify that you have purchased early access before downloading.')}</div>
@@ -134,7 +145,7 @@ export class RecipeDataManager {
} }
// Format size if available // Format size if available
const sizeDisplay = lora.size ? const sizeDisplay = lora.size ?
`<div class="size-badge">${this.importManager.formatFileSize(lora.size)}</div>` : ''; `<div class="size-badge">${this.importManager.formatFileSize(lora.size)}</div>` : '';
return ` return `
@@ -161,9 +172,9 @@ export class RecipeDataManager {
`; `;
}).join(''); }).join('');
} }
// Check for early access loras and show warning if any exist // Check for early access loras and show warning if any exist
const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora => const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora =>
lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted); lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted);
if (earlyAccessLoras.length > 0) { if (earlyAccessLoras.length > 0) {
// Show a warning about early access loras // Show a warning about early access loras
@@ -179,7 +190,7 @@ export class RecipeDataManager {
</div> </div>
</div> </div>
`; `;
// Show the warning message // Show the warning message
const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); const buttonsContainer = document.querySelector('#detailsStep .modal-actions');
if (buttonsContainer) { if (buttonsContainer) {
@@ -188,7 +199,7 @@ export class RecipeDataManager {
if (existingWarning) { if (existingWarning) {
existingWarning.remove(); existingWarning.remove();
} }
// Add new warning // Add new warning
const warningContainer = document.createElement('div'); const warningContainer = document.createElement('div');
warningContainer.id = 'earlyAccessWarning'; warningContainer.id = 'earlyAccessWarning';
@@ -196,27 +207,27 @@ export class RecipeDataManager {
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
} }
} }
// Check for duplicate recipes and display warning if found // Check for duplicate recipes and display warning if found
this.checkAndDisplayDuplicates(); this.checkAndDisplayDuplicates();
// Update Next button state based on missing LoRAs and duplicates // Update Next button state based on missing LoRAs and duplicates
this.updateNextButtonState(); this.updateNextButtonState();
} }
checkAndDisplayDuplicates() { checkAndDisplayDuplicates() {
// Check if we have duplicate recipes // Check if we have duplicate recipes
if (this.importManager.recipeData && if (this.importManager.recipeData &&
this.importManager.recipeData.matching_recipes && this.importManager.recipeData.matching_recipes &&
this.importManager.recipeData.matching_recipes.length > 0) { this.importManager.recipeData.matching_recipes.length > 0) {
// Store duplicates in the importManager for later use // Store duplicates in the importManager for later use
this.importManager.duplicateRecipes = this.importManager.recipeData.matching_recipes; this.importManager.duplicateRecipes = this.importManager.recipeData.matching_recipes;
// Create duplicate warning container // Create duplicate warning container
const duplicateContainer = document.getElementById('duplicateRecipesContainer') || const duplicateContainer = document.getElementById('duplicateRecipesContainer') ||
this.createDuplicateContainer(); this.createDuplicateContainer();
// Format date helper function // Format date helper function
const formatDate = (timestamp) => { const formatDate = (timestamp) => {
try { try {
@@ -226,7 +237,7 @@ export class RecipeDataManager {
return 'Unknown date'; return 'Unknown date';
} }
}; };
// Generate the HTML for duplicate recipes warning // Generate the HTML for duplicate recipes warning
duplicateContainer.innerHTML = ` duplicateContainer.innerHTML = `
<div class="duplicate-warning"> <div class="duplicate-warning">
@@ -262,10 +273,10 @@ export class RecipeDataManager {
`).join('')} `).join('')}
</div> </div>
`; `;
// Show the duplicate container // Show the duplicate container
duplicateContainer.style.display = 'block'; duplicateContainer.style.display = 'block';
// Add click event for the toggle button // Add click event for the toggle button
const toggleButton = document.getElementById('toggleDuplicatesList'); const toggleButton = document.getElementById('toggleDuplicatesList');
if (toggleButton) { if (toggleButton) {
@@ -290,49 +301,49 @@ export class RecipeDataManager {
if (duplicateContainer) { if (duplicateContainer) {
duplicateContainer.style.display = 'none'; duplicateContainer.style.display = 'none';
} }
// Reset duplicate tracking // Reset duplicate tracking
this.importManager.duplicateRecipes = []; this.importManager.duplicateRecipes = [];
} }
} }
createDuplicateContainer() { createDuplicateContainer() {
// Find where to insert the duplicate container // Find where to insert the duplicate container
const lorasListContainer = document.querySelector('.input-group:has(#lorasList)'); const lorasListContainer = document.querySelector('.input-group:has(#lorasList)');
if (!lorasListContainer) return null; if (!lorasListContainer) return null;
// Create container // Create container
const duplicateContainer = document.createElement('div'); const duplicateContainer = document.createElement('div');
duplicateContainer.id = 'duplicateRecipesContainer'; duplicateContainer.id = 'duplicateRecipesContainer';
duplicateContainer.className = 'duplicate-recipes-container'; duplicateContainer.className = 'duplicate-recipes-container';
// Insert before the LoRA list // Insert before the LoRA list
lorasListContainer.parentNode.insertBefore(duplicateContainer, lorasListContainer); lorasListContainer.parentNode.insertBefore(duplicateContainer, lorasListContainer);
return duplicateContainer; return duplicateContainer;
} }
updateNextButtonState() { updateNextButtonState() {
const nextButton = document.querySelector('#detailsStep .primary-btn'); const nextButton = document.querySelector('#detailsStep .primary-btn');
const actionsContainer = document.querySelector('#detailsStep .modal-actions'); const actionsContainer = document.querySelector('#detailsStep .modal-actions');
if (!nextButton || !actionsContainer) return; if (!nextButton || !actionsContainer) return;
// Always clean up previous warnings and buttons first // Always clean up previous warnings and buttons first
const existingWarning = document.getElementById('deletedLorasWarning'); const existingWarning = document.getElementById('deletedLorasWarning');
if (existingWarning) { if (existingWarning) {
existingWarning.remove(); existingWarning.remove();
} }
// Remove any existing "import anyway" button // Remove any existing "import anyway" button
const importAnywayBtn = document.getElementById('importAnywayBtn'); const importAnywayBtn = document.getElementById('importAnywayBtn');
if (importAnywayBtn) { if (importAnywayBtn) {
importAnywayBtn.remove(); importAnywayBtn.remove();
} }
// Count deleted LoRAs // Count deleted LoRAs
const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length; const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length;
// If we have deleted LoRAs, show a warning // If we have deleted LoRAs, show a warning
if (deletedLoras > 0) { if (deletedLoras > 0) {
// Create a new warning container above the buttons // Create a new warning container above the buttons
@@ -340,7 +351,7 @@ export class RecipeDataManager {
const warningContainer = document.createElement('div'); const warningContainer = document.createElement('div');
warningContainer.id = 'deletedLorasWarning'; warningContainer.id = 'deletedLorasWarning';
warningContainer.className = 'deleted-loras-warning'; warningContainer.className = 'deleted-loras-warning';
// Create warning message // Create warning message
warningContainer.innerHTML = ` warningContainer.innerHTML = `
<div class="warning-icon"><i class="fas fa-exclamation-triangle"></i></div> <div class="warning-icon"><i class="fas fa-exclamation-triangle"></i></div>
@@ -349,19 +360,19 @@ export class RecipeDataManager {
<div class="warning-text">These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.</div> <div class="warning-text">These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.</div>
</div> </div>
`; `;
// Insert before the buttons container // Insert before the buttons container
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
} }
// Check for duplicates but don't change button actions // Check for duplicates but don't change button actions
const missingNotDeleted = this.importManager.recipeData.loras.filter( const missingNotDeleted = this.importManager.recipeData.loras.filter(
lora => !lora.existsLocally && !lora.isDeleted lora => !lora.existsLocally && !lora.isDeleted
).length; ).length;
// Standard button behavior regardless of duplicates // Standard button behavior regardless of duplicates
nextButton.classList.remove('warning-btn'); nextButton.classList.remove('warning-btn');
if (missingNotDeleted > 0) { if (missingNotDeleted > 0) {
nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs'); nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
} else { } else {
@@ -372,30 +383,30 @@ export class RecipeDataManager {
addTag() { addTag() {
const tagInput = document.getElementById('tagInput'); const tagInput = document.getElementById('tagInput');
const tag = tagInput.value.trim(); const tag = tagInput.value.trim();
if (!tag) return; if (!tag) return;
if (!this.importManager.recipeTags.includes(tag)) { if (!this.importManager.recipeTags.includes(tag)) {
this.importManager.recipeTags.push(tag); this.importManager.recipeTags.push(tag);
this.updateTagsDisplay(); this.updateTagsDisplay();
} }
tagInput.value = ''; tagInput.value = '';
} }
removeTag(tag) { removeTag(tag) {
this.importManager.recipeTags = this.importManager.recipeTags.filter(t => t !== tag); this.importManager.recipeTags = this.importManager.recipeTags.filter(t => t !== tag);
this.updateTagsDisplay(); this.updateTagsDisplay();
} }
updateTagsDisplay() { updateTagsDisplay() {
const tagsContainer = document.getElementById('tagsContainer'); const tagsContainer = document.getElementById('tagsContainer');
if (this.importManager.recipeTags.length === 0) { if (this.importManager.recipeTags.length === 0) {
tagsContainer.innerHTML = `<div class="empty-tags">${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}</div>`; tagsContainer.innerHTML = `<div class="empty-tags">${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}</div>`;
return; return;
} }
tagsContainer.innerHTML = this.importManager.recipeTags.map(tag => ` tagsContainer.innerHTML = this.importManager.recipeTags.map(tag => `
<div class="recipe-tag"> <div class="recipe-tag">
${tag} ${tag}
@@ -410,7 +421,7 @@ export class RecipeDataManager {
showToast('toast.recipes.enterRecipeName', {}, 'error'); showToast('toast.recipes.enterRecipeName', {}, 'error');
return; return;
} }
// Automatically mark all deleted LoRAs as excluded // Automatically mark all deleted LoRAs as excluded
if (this.importManager.recipeData && this.importManager.recipeData.loras) { if (this.importManager.recipeData && this.importManager.recipeData.loras) {
this.importManager.recipeData.loras.forEach(lora => { this.importManager.recipeData.loras.forEach(lora => {
@@ -419,11 +430,11 @@ export class RecipeDataManager {
} }
}); });
} }
// Update missing LoRAs list to exclude deleted LoRAs // Update missing LoRAs list to exclude deleted LoRAs
this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora => this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora =>
!lora.existsLocally && !lora.isDeleted); !lora.existsLocally && !lora.isDeleted);
// If we have downloadable missing LoRAs, go to location step // If we have downloadable missing LoRAs, go to location step
if (this.importManager.missingLoras.length > 0) { if (this.importManager.missingLoras.length > 0) {
// Store only downloadable LoRAs for the download step // Store only downloadable LoRAs for the download step

View File

@@ -95,7 +95,7 @@ async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monke
temp_path = tmp_path / "temp.jpg" temp_path = tmp_path / "temp.jpg"
def create_temp_path(): def create_temp_path(suffix=".jpg"):
temp_path.write_bytes(b"") temp_path.write_bytes(b"")
return str(temp_path) return str(temp_path)
@@ -401,3 +401,55 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
assert stored["loras"] == [] assert stored["loras"] == []
assert stored["title"] == "recipe" assert stored["title"] == "recipe"
assert scanner.added and scanner.added[0]["loras"] == [] assert scanner.added and scanner.added[0]["loras"] == []
@pytest.mark.asyncio
async def test_analyze_remote_video(tmp_path):
exif_utils = DummyExifUtils()
class DummyFactory:
def create_parser(self, metadata):
async def parse_metadata(m, recipe_scanner):
return {"loras": []}
return SimpleNamespace(parse_metadata=parse_metadata)
async def downloader_factory():
class Downloader:
async def download_file(self, url, path, use_auth=False):
Path(path).write_bytes(b"video-content")
return True, "success"
return Downloader()
service = RecipeAnalysisService(
exif_utils=exif_utils,
recipe_parser_factory=DummyFactory(),
downloader_factory=downloader_factory,
metadata_collector=None,
metadata_processor_cls=None,
metadata_registry_cls=None,
standalone_mode=False,
logger=logging.getLogger("test"),
)
class DummyClient:
async def get_image_info(self, image_id):
return {
"url": "https://civitai.com/video.mp4",
"type": "video",
"meta": {"prompt": "video prompt"},
}
class DummyScanner:
async def find_recipes_by_fingerprint(self, fingerprint):
return []
result = await service.analyze_remote_image(
url="https://civitai.com/images/123",
recipe_scanner=DummyScanner(),
civitai_client=DummyClient(),
)
assert result.payload["is_video"] is True
assert result.payload["extension"] == ".mp4"
assert result.payload["image_base64"] is not None