diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py
index 911de839..35469eaf 100644
--- a/py/routes/handlers/recipe_handlers.py
+++ b/py/routes/handlers/recipe_handlers.py
@@ -437,6 +437,7 @@ class RecipeManagementHandler:
name=payload["name"],
tags=payload["tags"],
metadata=payload["metadata"],
+ extension=payload.get("extension"),
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
@@ -625,6 +626,7 @@ class RecipeManagementHandler:
name: Optional[str] = None
tags: list[str] = []
metadata: Optional[Dict[str, Any]] = None
+ extension: Optional[str] = None
while True:
field = await reader.next()
@@ -655,6 +657,8 @@ class RecipeManagementHandler:
metadata = json.loads(metadata_text)
except Exception:
metadata = {}
+ elif field.name == "extension":
+ extension = await field.text()
return {
"image_bytes": image_bytes,
@@ -662,6 +666,7 @@ class RecipeManagementHandler:
"name": name,
"tags": tags,
"metadata": metadata,
+ "extension": extension,
}
def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
diff --git a/py/services/recipes/analysis_service.py b/py/services/recipes/analysis_service.py
index b7c76afd..cf709743 100644
--- a/py/services/recipes/analysis_service.py
+++ b/py/services/recipes/analysis_service.py
@@ -13,6 +13,7 @@ import numpy as np
from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint
+from ...utils.civitai_utils import rewrite_preview_url
from .errors import (
RecipeDownloadError,
RecipeNotFoundError,
@@ -94,18 +95,39 @@ class RecipeAnalysisService:
if civitai_client is None:
raise RecipeServiceError("Civitai client unavailable")
- temp_path = self._create_temp_path()
+ temp_path = None
metadata: Optional[dict[str, Any]] = None
+ is_video = False
+ extension = ".jpg" # Default
+
try:
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
if civitai_match:
image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai")
+
image_url = image_info.get("url")
if not image_url:
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)
+
metadata = image_info.get("meta") if "meta" in image_info else None
if (
isinstance(metadata, dict)
@@ -114,22 +136,31 @@ class RecipeAnalysisService:
):
metadata = metadata["meta"]
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)
- if metadata is None:
+ if metadata is None and not is_video:
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(
- metadata,
+ metadata or {},
recipe_scanner=recipe_scanner,
image_path=temp_path,
include_image_base64=True,
+ is_video=is_video,
+ extension=extension,
)
finally:
- self._safe_cleanup(temp_path)
+ if temp_path:
+ self._safe_cleanup(temp_path)
async def analyze_local_image(
self,
@@ -198,12 +229,16 @@ class RecipeAnalysisService:
recipe_scanner,
image_path: Optional[str],
include_image_base64: bool,
+ is_video: bool = False,
+ extension: str = ".jpg",
) -> AnalysisResult:
parser = self._recipe_parser_factory.create_parser(metadata)
if parser is None:
payload = {"error": "No parser found for this image", "loras": []}
if include_image_base64 and image_path:
payload["image_base64"] = self._encode_file(image_path)
+ payload["is_video"] = is_video
+ payload["extension"] = extension
return AnalysisResult(payload)
result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner)
@@ -211,6 +246,9 @@ class RecipeAnalysisService:
if include_image_base64 and 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"):
return AnalysisResult(result)
@@ -241,8 +279,8 @@ class RecipeAnalysisService:
temp_file.write(data)
return temp_file.name
- def _create_temp_path(self) -> str:
- with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file:
+ def _create_temp_path(self, suffix: str = ".jpg") -> str:
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
return temp_file.name
def _safe_cleanup(self, path: Optional[str]) -> None:
diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css
index 82c34672..89be93ad 100644
--- a/static/css/components/import-modal.css
+++ b/static/css/components/import-modal.css
@@ -1,7 +1,8 @@
/* Import Modal Styles */
.import-step {
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 */
@@ -107,7 +108,8 @@
justify-content: center;
}
-.recipe-image img {
+.recipe-image img,
+.recipe-preview-video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
@@ -379,7 +381,7 @@
.recipe-details-layout {
grid-template-columns: 1fr;
}
-
+
.recipe-image-container {
height: 150px;
}
@@ -512,14 +514,17 @@
/* Prevent layout shift with scrollbar */
.modal-content {
- overflow-y: scroll; /* Always show scrollbar */
- scrollbar-gutter: stable; /* Reserve space for scrollbar */
+ overflow-y: scroll;
+ /* Always show scrollbar */
+ scrollbar-gutter: stable;
+ /* Reserve space for scrollbar */
}
/* For browsers that don't support scrollbar-gutter */
@supports not (scrollbar-gutter: stable) {
.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 */
.warning-message {
- display: none; /* Hide the old style */
+ display: none;
+ /* Hide the old style */
}
/* Update deleted badge to be more prominent */
@@ -613,7 +619,8 @@
color: var(--lora-error);
font-size: 0.9em;
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;
}
@@ -662,8 +669,15 @@
}
@keyframes fadeIn {
- from { opacity: 0; transform: translateY(-10px); }
- to { opacity: 1; transform: translateY(0); }
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
.duplicate-warning {
@@ -779,6 +793,7 @@
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
+ line-clamp: 2;
-webkit-box-orient: vertical;
}
@@ -793,9 +808,9 @@
opacity: 0.8;
}
-.duplicate-recipe-date,
+.duplicate-recipe-date,
.duplicate-recipe-lora-count {
display: flex;
align-items: center;
gap: 4px;
-}
+}
\ No newline at end of file
diff --git a/static/js/managers/import/DownloadManager.js b/static/js/managers/import/DownloadManager.js
index dc4b24b7..4e4d1b41 100644
--- a/static/js/managers/import/DownloadManager.js
+++ b/static/js/managers/import/DownloadManager.js
@@ -12,21 +12,21 @@ export class DownloadManager {
async saveRecipe() {
// Check if we're in download-only mode (for existing recipe)
const isDownloadOnly = !!this.importManager.recipeId;
-
+
if (!isDownloadOnly && !this.importManager.recipeName) {
showToast('toast.recipes.enterRecipeName', {}, 'error');
return;
}
-
+
try {
// Show progress indicator
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
if (!isDownloadOnly) {
// Create FormData object for saving recipe
const formData = new FormData();
-
+
// Add image data - depends on import mode
if (this.importManager.recipeImage) {
// Direct upload
@@ -45,10 +45,10 @@ export class DownloadManager {
} else {
throw new Error('No image data available');
}
-
+
formData.append('name', this.importManager.recipeName);
formData.append('tags', JSON.stringify(this.importManager.recipeTags));
-
+
// Prepare complete metadata including generation parameters
const completeMetadata = {
base_model: this.importManager.recipeData.base_model || "",
@@ -65,7 +65,11 @@ export class DownloadManager {
if (checkpointMetadata && typeof checkpointMetadata === 'object') {
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
if (this.importManager.importMode === 'url') {
const urlInput = document.getElementById('imageUrlInput');
@@ -73,15 +77,15 @@ export class DownloadManager {
completeMetadata.source_path = urlInput.value;
}
}
-
+
formData.append('metadata', JSON.stringify(completeMetadata));
-
+
// Send save request
const response = await fetch('/api/lm/recipes/save', {
method: 'POST',
body: formData
});
-
+
const result = await response.json();
if (!result.success) {
@@ -102,19 +106,19 @@ export class DownloadManager {
// Show success message
if (isDownloadOnly) {
- if (failedDownloads === 0) {
+ if (failedDownloads === 0) {
showToast('toast.loras.downloadSuccessful', {}, 'success');
}
} else {
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
}
-
+
// Close modal
modalManager.closeModal('importModal');
-
+
// Refresh the recipe
window.recipeManager.loadRecipes();
-
+
} catch (error) {
console.error('Error:', error);
showToast('toast.recipes.processingError', { message: error.message }, 'error');
@@ -129,49 +133,49 @@ export class DownloadManager {
if (!loraRoot) {
throw new Error(translate('recipes.controls.import.errors.selectLoraRoot', {}, 'Please select a LoRA root directory'));
}
-
+
// Build target path
let targetPath = '';
if (this.importManager.selectedFolder) {
targetPath = this.importManager.selectedFolder;
}
-
+
// Generate a unique ID for this batch download
const batchDownloadId = Date.now().toString();
-
+
// Set up WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
-
+
// Show enhanced loading with progress details for multiple items
const updateProgress = this.importManager.loadingManager.showDownloadProgress(
this.importManager.downloadableLoRAs.length
);
-
+
let completedDownloads = 0;
let failedDownloads = 0;
let accessFailures = 0;
let currentLoraProgress = 0;
-
+
// Set up progress tracking for current download
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
-
+
// Handle download ID confirmation
if (data.type === 'download_id') {
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
return;
}
-
+
// Process progress updates for our current active download
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
// Update current LoRA progress
currentLoraProgress = data.progress;
-
+
// Get current LoRA name
const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads];
const loraName = currentLora ? currentLora.name : '';
-
+
// Update progress display
const metrics = {
bytesDownloaded: data.bytes_downloaded,
@@ -180,7 +184,7 @@ export class DownloadManager {
};
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
-
+
// Add more detailed status messages based on progress
if (currentLoraProgress < 3) {
this.importManager.loadingManager.setStatus(
@@ -203,17 +207,17 @@ export class DownloadManager {
};
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
-
+
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
const lora = this.importManager.downloadableLoRAs[i];
-
+
// Reset current LoRA progress for new download
currentLoraProgress = 0;
-
+
// 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);
-
+
try {
// Download the LoRA with download ID
const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel(
@@ -224,7 +228,7 @@ export class DownloadManager {
useDefaultPaths,
batchDownloadId
);
-
+
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
@@ -248,28 +252,28 @@ export class DownloadManager {
// Continue with next download
}
}
-
+
// Close WebSocket
ws.close();
-
+
// Show appropriate completion message based on results
if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
if (accessFailures > 0) {
- showToast('toast.loras.downloadPartialWithAccess', {
- completed: completedDownloads,
+ showToast('toast.loras.downloadPartialWithAccess', {
+ completed: completedDownloads,
total: this.importManager.downloadableLoRAs.length,
accessFailures: accessFailures
}, 'error');
} else {
- showToast('toast.loras.downloadPartialSuccess', {
- completed: completedDownloads,
- total: this.importManager.downloadableLoRAs.length
+ showToast('toast.loras.downloadPartialSuccess', {
+ completed: completedDownloads,
+ total: this.importManager.downloadableLoRAs.length
}, 'error');
}
}
-
+
return failedDownloads;
}
}
diff --git a/static/js/managers/import/RecipeDataManager.js b/static/js/managers/import/RecipeDataManager.js
index 8f351859..da11f25c 100644
--- a/static/js/managers/import/RecipeDataManager.js
+++ b/static/js/managers/import/RecipeDataManager.js
@@ -8,10 +8,10 @@ export class RecipeDataManager {
showRecipeDetailsStep() {
this.importManager.stepManager.showStep('detailsStep');
-
+
// Set default recipe name from prompt or image filename
const recipeName = document.getElementById('recipeName');
-
+
// Check if we have recipe metadata from a shared recipe
if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) {
// Use title from recipe metadata
@@ -19,24 +19,24 @@ export class RecipeDataManager {
recipeName.value = this.importManager.recipeData.title;
this.importManager.recipeName = this.importManager.recipeData.title;
}
-
+
// Use tags from recipe metadata
if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) {
this.importManager.recipeTags = [...this.importManager.recipeData.tags];
this.updateTagsDisplay();
}
- } else if (this.importManager.recipeData &&
- this.importManager.recipeData.gen_params &&
- this.importManager.recipeData.gen_params.prompt) {
+ } else if (this.importManager.recipeData &&
+ this.importManager.recipeData.gen_params &&
+ this.importManager.recipeData.gen_params.prompt) {
// Use the first 10 words from the prompt as the default recipe name
const promptWords = this.importManager.recipeData.gen_params.prompt.split(' ');
const truncatedPrompt = promptWords.slice(0, 10).join(' ');
recipeName.value = truncatedPrompt;
this.importManager.recipeName = truncatedPrompt;
-
+
// Set up click handler to select all text for easy editing
if (!recipeName.hasSelectAllHandler) {
- recipeName.addEventListener('click', function() {
+ recipeName.addEventListener('click', function () {
this.select();
});
recipeName.hasSelectAllHandler = true;
@@ -47,15 +47,15 @@ export class RecipeDataManager {
recipeName.value = fileName;
this.importManager.recipeName = fileName;
}
-
+
// Always set up click handler for easy editing if not already set
if (!recipeName.hasSelectAllHandler) {
- recipeName.addEventListener('click', function() {
+ recipeName.addEventListener('click', function () {
this.select();
});
recipeName.hasSelectAllHandler = true;
}
-
+
// Display the uploaded image in the preview
const imagePreview = document.getElementById('recipeImagePreview');
if (imagePreview) {
@@ -67,13 +67,24 @@ export class RecipeDataManager {
};
reader.readAsDataURL(this.importManager.recipeImage);
} else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) {
- // For URL mode - use the base64 image data returned from the backend
- imagePreview.innerHTML = ``;
+ // For URL mode - use the base64 data returned from the backend
+ if (this.importManager.recipeData.is_video) {
+ const mimeType = this.importManager.recipeData.extension === '.webm' ? 'video/webm' : 'video/mp4';
+ imagePreview.innerHTML = ``;
+ } else {
+ imagePreview.innerHTML = `
`;
+ }
} else if (this.importManager.importMode === 'url') {
// Fallback for URL mode if no base64 data
const urlInput = document.getElementById('imageUrlInput');
if (urlInput && urlInput.value) {
- imagePreview.innerHTML = `
`;
+ const url = urlInput.value.toLowerCase();
+ if (url.endsWith('.mp4') || url.endsWith('.webm')) {
+ const mimeType = url.endsWith('.webm') ? 'video/webm' : 'video/mp4';
+ imagePreview.innerHTML = ``;
+ } else {
+ imagePreview.innerHTML = `
`;
+ }
}
}
}
@@ -85,7 +96,7 @@ export class RecipeDataManager {
if (loraCountInfo) {
loraCountInfo.textContent = translate('recipes.controls.import.loraCountInfo', { existing: existingLoras, total: totalLoras }, `(${existingLoras}/${totalLoras} in library)`);
}
-
+
// Display LoRAs list
const lorasList = document.getElementById('lorasList');
if (lorasList) {
@@ -94,7 +105,7 @@ export class RecipeDataManager {
const isDeleted = lora.isDeleted;
const isEarlyAccess = lora.isEarlyAccess;
const localPath = lora.localPath || '';
-
+
// Create status badge based on LoRA status
let statusBadge;
if (isDeleted) {
@@ -102,7 +113,7 @@ export class RecipeDataManager {
${translate('recipes.controls.import.deletedFromCivitai', {}, 'Deleted from Civitai')}
`;
} else {
- statusBadge = existsLocally ?
+ statusBadge = existsLocally ?
`