Files
ComfyUI-Lora-Manager/static/js/managers/import/DownloadManager.js
T
Will Miao 69691b17a1 feat(recipes): preserve embedded ComfyUI workflow on remote imports
CivitAI serves a re-encoded, metadata-free optimized rendition as the recipe
preview, so the ComfyUI workflow embedded in the original image was dropped:
imported recipes reported has_workflow=false and never offered "Send Workflow
to ComfyUI" even when the source image carried one.

Recover the workflow from the original rendition and carry it to the save step
as data, so the stored preview stays the small optimized image:

- ExifUtils: embed a caller-supplied workflow during optimize_image's single
  encode pass, and add embed_workflow() to patch WebP EXIF in place (used by
  the verbatim skip_optimize branch and as a safety net).
- RecipePersistenceService.save_recipe: embed metadata["workflow"] before
  detecting has_workflow.
- analyze_remote_image: return the workflow recovered from the original
  rendition it already downloads for EXIF parsing.
- RecipeManagementHandler: add _fetch_original_media() and workflow helpers;
  _do_import_from_url reuses them, and _do_import_remote_recipe fetches the
  original only when CivitAI reports a ComfyUI payload (meta.comfy) so
  workflow-less images pay no extra bandwidth.
- Batch URL imports and the import modal forward the recovered workflow.

Verified against the reported image: has_workflow flips from false to true and
the recovered workflow matches the original (25 nodes, same graph id).
2026-09-29 07:20:12 +08:00

351 lines
16 KiB
JavaScript

import { showToast } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
import { state } from '../../state/index.js';
export class DownloadManager {
constructor(importManager) {
this.importManager = importManager;
}
async saveRecipe(skipDownload = false) {
// 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;
}
console.log(
`[RecipeImport] Saving recipe "${this.importManager.recipeName}" (download-only=${isDownloadOnly}, skipDownload=${skipDownload})`
);
try {
// Show progress indicator
const loadingMessage = skipDownload
? translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...')
: (isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
this.importManager.loadingManager.showSimpleLoading(loadingMessage);
// 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
formData.append('image', this.importManager.recipeImage);
} else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) {
// URL mode with base64 data
formData.append('image_base64', this.importManager.recipeData.image_base64);
} else if (this.importManager.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.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 || "",
loras: this.importManager.recipeData.loras || [],
gen_params: this.importManager.recipeData.gen_params || {},
raw_metadata: this.importManager.recipeData.raw_metadata || {},
};
// Pass analysis diagnostics through so the backend can record
// why the recipe ended up with no LoRAs (recipe modal panel).
const diagnostics = this.importManager.recipeData.diagnostics;
if (diagnostics && typeof diagnostics === 'object') {
completeMetadata.diagnostics = diagnostics;
}
// A ComfyUI workflow recovered from the source's original
// rendition: CivitAI's optimized preview is re-encoded and
// metadata-free, so the workflow travels as data and the
// backend embeds it into the stored image.
const workflow = this.importManager.recipeData.workflow;
if (workflow) {
completeMetadata.workflow = workflow;
}
// Preserve preview_nsfw_level from analysis so the saved
// recipe applies the correct NSFW blur on the preview image.
const nsfwLevel = this.importManager.recipeData.preview_nsfw_level;
if (nsfwLevel !== undefined && nsfwLevel !== null) {
completeMetadata.preview_nsfw_level = nsfwLevel;
}
const checkpointMetadata =
this.importManager.recipeData.checkpoint ||
this.importManager.recipeData.model ||
(this.importManager.recipeData.gen_params || {}).checkpoint;
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');
if (urlInput && urlInput.value) {
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) {
// Handle save error
console.error("Failed to save recipe:", result.error);
console.log('[RecipeImport] Save failed; closing import modal.');
showToast('toast.recipes.recipeSaveFailed', { error: result.error }, 'error');
// Close modal
modalManager.closeModal('importModal');
return;
}
}
// Check if we need to download LoRAs (skip if skipDownload is true)
let failedDownloads = 0;
if (!skipDownload && this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
console.log(`[RecipeImport] Downloading ${this.importManager.downloadableLoRAs.length} missing LoRA(s)...`);
await this.downloadMissingLoras();
}
// Show success message
if (isDownloadOnly) {
if (skipDownload) {
showToast('toast.recipes.recipeSaved', {}, 'success');
} else if (failedDownloads === 0) {
showToast('toast.loras.downloadSuccessful', {}, 'success');
}
} else {
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
}
modalManager.closeModal('importModal');
console.log(`[RecipeImport] Recipe "${this.importManager.recipeName}" saved successfully.`);
if (isDownloadOnly && state.virtualScroller) {
const recipeId = this.importManager.recipeId;
try {
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
if (detailRes.ok) {
const updated = await detailRes.json();
state.virtualScroller.updateSingleItem(updated.file_path, updated);
} else {
throw new Error(`API returned ${detailRes.status}`);
}
} catch (e) {
console.warn('Failed to update recipe card in-place, falling back to reload:', e);
await window.recipeManager.loadRecipes({ resetPage: true, preserveScroll: true });
}
} else {
window.recipeManager.loadRecipes({ resetPage: true, preserveScroll: true });
}
} catch (error) {
console.error('Error:', error);
showToast('toast.recipes.processingError', { message: error.message }, 'error');
} finally {
this.importManager.loadingManager.hide();
}
}
async downloadMissingLoras() {
// For download, we need to validate the target path
const loraRoot = document.getElementById('importLoraRoot')?.value;
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;
let cancelled = false;
this.importManager.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
const loraClient = getModelApiClient(MODEL_TYPES.LORA);
await loraClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
// 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;
}
if (data.status === 'cancelled') {
cancelled = true;
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,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second
};
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
// Add more detailed status messages based on progress
if (currentLoraProgress < 3) {
this.importManager.loadingManager.setStatus(
`Preparing download for LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}`
);
} else if (currentLoraProgress === 3) {
this.importManager.loadingManager.setStatus(
`Downloaded preview for LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}`
);
} else if (currentLoraProgress > 3 && currentLoraProgress < 100) {
this.importManager.loadingManager.setStatus(
`Downloading LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}`
);
} else {
this.importManager.loadingManager.setStatus(
`Finalizing LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}`
);
}
}
};
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
if (cancelled) break;
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}`));
updateProgress(0, completedDownloads, lora.name);
try {
// Download the LoRA with download ID
const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel(
lora.modelId,
lora.id,
loraRoot,
targetPath.replace(loraRoot + '/', ''),
useDefaultPaths,
batchDownloadId
);
if (cancelled) break;
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
failedDownloads++;
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
this.importManager.loadingManager.setStatus(
`Completed ${completedDownloads}/${this.importManager.downloadableLoRAs.length} LoRAs. Starting next download...`
);
}
}
} catch (downloadError) {
if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
failedDownloads++;
}
}
}
// Close WebSocket
ws.close();
// Show appropriate completion message based on results
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
if (accessFailures > 0) {
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
}, 'error');
}
}
return failedDownloads;
}
}