diff --git a/src/components/honeyToast/HoneyToast.test.ts b/src/components/honeyToast/HoneyToast.test.ts index ada1230531..7174208fed 100644 --- a/src/components/honeyToast/HoneyToast.test.ts +++ b/src/components/honeyToast/HoneyToast.test.ts @@ -67,18 +67,6 @@ describe('HoneyToast', () => { wrapper.unmount() }) - it('applies collapsed max-height class when collapsed', async () => { - const wrapper = mountComponent({ visible: true, expanded: false }) - await nextTick() - - const expandableArea = document.body.querySelector( - '[role="status"] > div:first-child' - ) - expect(expandableArea?.classList.contains('max-h-0')).toBe(true) - - wrapper.unmount() - }) - it('has aria-live="polite" for accessibility', async () => { const wrapper = mountComponent({ visible: true }) await nextTick() @@ -127,11 +115,6 @@ describe('HoneyToast', () => { expect(content?.textContent).toBe('expanded') expect(toggleBtn?.textContent?.trim()).toBe('Collapse') - const expandableArea = document.body.querySelector( - '[role="status"] > div:first-child' - ) - expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true) - wrapper.unmount() }) }) diff --git a/src/components/honeyToast/HoneyToast.vue b/src/components/honeyToast/HoneyToast.vue index a7d86ba77e..9e6e191f61 100644 --- a/src/components/honeyToast/HoneyToast.vue +++ b/src/components/honeyToast/HoneyToast.vue @@ -26,13 +26,13 @@ function toggle() { v-if="visible" role="status" aria-live="polite" - class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg" + class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300" >
diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 404c5caf5b..5bd6498595 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2817,9 +2817,10 @@ "insertAllAssetsAsNodes": "Insert all assets as nodes", "openWorkflowAll": "Open all workflows", "exportWorkflowAll": "Export all workflows", - "downloadStarted": "Downloading {count} files...", - "downloadsStarted": "Started downloading {count} file(s)", - "assetsDeletedSuccessfully": "{count} asset(s) deleted successfully", + "downloadStarted": "Downloading {count} file... | Downloading {count} files...", + "downloadsStarted": "Started downloading {count} file | Started downloading {count} files", + "exportStarted": "Preparing ZIP export for {count} file | Preparing ZIP export for {count} files", + "assetsDeletedSuccessfully": "{count} asset deleted successfully | {count} assets deleted successfully", "failedToDeleteAssets": "Failed to delete selected assets", "partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed", "nodesAddedToWorkflow": "{count} node(s) added to workflow", @@ -3015,6 +3016,20 @@ "failed": "Failed" } }, + "exportToast": { + "exportingAssets": "Exporting Assets", + "preparingExport": "Preparing export...", + "exportError": "Export failed", + "exportFailed": "{count} export failed | {count} export failed | {count} exports failed", + "allExportsCompleted": "All exports completed", + "noExportsInQueue": "No {filter} exports in queue", + "exportStarted": "Preparing ZIP download...", + "exportCompleted": "ZIP download ready", + "exportFailedSingle": "Failed to create ZIP export", + "downloadExport": "Download export", + "downloadFailed": "Failed to download \"{name}\"", + "retryDownload": "Retry download" + }, "workspace": { "unsavedChanges": { "title": "Unsaved Changes", diff --git a/src/platform/assets/components/AssetExportProgressDialog.vue b/src/platform/assets/components/AssetExportProgressDialog.vue new file mode 100644 index 0000000000..da1f0f528d --- /dev/null +++ b/src/platform/assets/components/AssetExportProgressDialog.vue @@ -0,0 +1,254 @@ + + + diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index c4e3b79a43..b7008bde3f 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -20,6 +20,8 @@ import { createAnnotatedPath } from '@/utils/createAnnotatedPath' import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil' import { isResultItemType } from '@/utils/typeGuardUtil' +import { useAssetExportStore } from '@/stores/assetExportStore' + import type { AssetItem } from '../schemas/assetSchema' import { MediaAssetKey } from '../schemas/mediaAssetSchema' import { assetService } from '../services/assetService' @@ -73,7 +75,7 @@ export function useMediaAssetActions() { toast.add({ severity: 'success', summary: t('g.success'), - detail: t('mediaAsset.selection.downloadsStarted', { count: 1 }), + detail: t('mediaAsset.selection.downloadsStarted', 1), life: 2000 }) } catch (error) { @@ -87,16 +89,26 @@ export function useMediaAssetActions() { } /** - * Download multiple assets at once - * @param assets Array of assets to download + * Download multiple assets at once. + * In cloud mode with 2+ assets, creates a ZIP export via the backend. + * Falls back to individual downloads in OSS mode or for single assets. */ const downloadMultipleAssets = (assets: AssetItem[]) => { if (!assets || assets.length === 0) return + const hasMultiOutputJobs = assets.some((a) => { + const count = getOutputAssetMetadata(a.user_metadata)?.outputCount + return typeof count === 'number' && count > 1 + }) + + if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) { + void downloadMultipleAssetsAsZip(assets) + return + } + try { assets.forEach((asset) => { const filename = asset.name - // Prefer preview_url (already includes subfolder) with getAssetUrl as fallback const downloadUrl = asset.preview_url || getAssetUrl(asset) downloadFile(downloadUrl, filename) }) @@ -104,9 +116,7 @@ export function useMediaAssetActions() { toast.add({ severity: 'success', summary: t('g.success'), - detail: t('mediaAsset.selection.downloadsStarted', { - count: assets.length - }), + detail: t('mediaAsset.selection.downloadsStarted', assets.length), life: 2000 }) } catch (error) { @@ -120,6 +130,62 @@ export function useMediaAssetActions() { } } + async function downloadMultipleAssetsAsZip(assets: AssetItem[]) { + const assetExportStore = useAssetExportStore() + + try { + const jobIds: string[] = [] + const assetIds: string[] = [] + const jobAssetNameFilters: Record = {} + + for (const asset of assets) { + if (getAssetType(asset) === 'output') { + const metadata = getOutputAssetMetadata(asset.user_metadata) + const promptId = metadata?.promptId || asset.id + if (!jobIds.includes(promptId)) { + jobIds.push(promptId) + } + if (metadata?.promptId && asset.name) { + if (!jobAssetNameFilters[metadata.promptId]) { + jobAssetNameFilters[metadata.promptId] = [] + } + if (!jobAssetNameFilters[metadata.promptId].includes(asset.name)) { + jobAssetNameFilters[metadata.promptId].push(asset.name) + } + } + } else { + assetIds.push(asset.id) + } + } + + const result = await assetService.createAssetExport({ + ...(jobIds.length > 0 ? { job_ids: jobIds } : {}), + ...(assetIds.length > 0 ? { asset_ids: assetIds } : {}), + ...(Object.keys(jobAssetNameFilters).length > 0 + ? { job_asset_name_filters: jobAssetNameFilters } + : {}), + naming_strategy: 'preserve' + }) + + assetExportStore.trackExport(result.task_id) + + toast.add({ + severity: 'info', + summary: t('exportToast.exportStarted'), + detail: t('mediaAsset.selection.exportStarted', assets.length), + life: 3000 + }) + } catch (error) { + console.error('Failed to create asset export:', error) + toast.add({ + severity: 'error', + summary: t('g.error'), + detail: t('exportToast.exportFailedSingle'), + life: 3000 + }) + } + } + const copyJobId = async (asset?: AssetItem) => { const targetAsset = asset ?? mediaContext?.asset.value if (!targetAsset) return @@ -580,9 +646,10 @@ export function useMediaAssetActions() { summary: t('g.success'), detail: isSingle ? t('mediaAsset.assetDeletedSuccessfully') - : t('mediaAsset.selection.assetsDeletedSuccessfully', { - count: succeeded - }), + : t( + 'mediaAsset.selection.assetsDeletedSuccessfully', + succeeded + ), life: 2000 }) } else if (succeeded === 0) { diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index f5649b0253..9555a99f76 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -31,6 +31,17 @@ interface AssetRequestOptions extends PaginationOptions { includePublic?: boolean } +interface AssetExportOptions { + job_ids?: string[] + asset_ids?: string[] + naming_strategy?: + | 'group_by_job_id' + | 'prepend_job_id' + | 'preserve' + | 'asset_id' + job_asset_name_filters?: Record +} + /** * Maps CivitAI validation error codes to localized error messages */ @@ -153,6 +164,7 @@ function getLocalizedErrorMessage(errorCode: string): string { const ASSETS_ENDPOINT = '/assets' const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download' +const ASSETS_EXPORT_ENDPOINT = '/assets/export' const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n` const DEFAULT_LIMIT = 500 @@ -689,6 +701,34 @@ function createAssetService() { return result.data } + async function createAssetExport( + params: AssetExportOptions + ): Promise<{ task_id: string; status: string; message?: string }> { + const res = await api.fetchApi(ASSETS_EXPORT_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + }) + + if (!res.ok) { + throw new Error(`Failed to create asset export: ${res.status}`) + } + + return await res.json() + } + + async function getExportDownloadUrl( + exportName: string + ): Promise<{ url: string; expires_at?: string }> { + const res = await api.fetchApi(`/assets/exports/${exportName}`) + + if (!res.ok) { + throw new Error(`Failed to get export download URL: ${res.status}`) + } + + return await res.json() + } + return { getAssetModelFolders, getAssetModels, @@ -703,7 +743,9 @@ function createAssetService() { getAssetMetadata, uploadAssetFromUrl, uploadAssetFromBase64, - uploadAssetAsync + uploadAssetAsync, + createAssetExport, + getExportDownloadUrl } } diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 20bc8a61a6..68c584b167 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -148,6 +148,19 @@ const zAssetDownloadWsMessage = z.object({ error: z.string().optional() }) +const zAssetExportWsMessage = z.object({ + task_id: z.string(), + export_name: z.string().optional(), + assets_total: z.number(), + assets_attempted: z.number(), + assets_failed: z.number(), + bytes_total: z.number(), + bytes_processed: z.number(), + progress: z.number(), + status: z.enum(['created', 'running', 'completed', 'failed']), + error: z.string().optional() +}) + export type StatusWsMessageStatus = z.infer export type StatusWsMessage = z.infer export type ProgressWsMessage = z.infer @@ -168,6 +181,7 @@ export type NodeProgressState = z.infer export type ProgressStateWsMessage = z.infer export type FeatureFlagsWsMessage = z.infer export type AssetDownloadWsMessage = z.infer +export type AssetExportWsMessage = z.infer // End of ws messages export type NotificationWsMessage = z.infer diff --git a/src/scripts/api.ts b/src/scripts/api.ts index f6a2af139d..b131934f4b 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -22,6 +22,7 @@ import type { } from '@/platform/workflow/validation/schemas/workflowSchema' import type { AssetDownloadWsMessage, + AssetExportWsMessage, CustomNodesI18n, EmbeddingsResponse, ExecutedWsMessage, @@ -169,6 +170,7 @@ interface BackendApiCalls { progress_state: ProgressStateWsMessage feature_flags: FeatureFlagsWsMessage asset_download: AssetDownloadWsMessage + asset_export: AssetExportWsMessage } /** Dictionary of all api calls */ diff --git a/src/stores/assetExportStore.ts b/src/stores/assetExportStore.ts new file mode 100644 index 0000000000..a8fe9ada53 --- /dev/null +++ b/src/stores/assetExportStore.ts @@ -0,0 +1,199 @@ +import { useIntervalFn } from '@vueuse/core' +import { defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' + +import { assetService } from '@/platform/assets/services/assetService' +import { useToastStore } from '@/platform/updates/common/toastStore' +import { taskService } from '@/platform/tasks/services/taskService' +import type { AssetExportWsMessage } from '@/schemas/apiSchema' +import { api } from '@/scripts/api' +import { t } from '@/i18n' + +export interface AssetExport { + taskId: string + exportName: string + assetsTotal: number + assetsAttempted: number + assetsFailed: number + bytesTotal: number + bytesProcessed: number + progress: number + status: 'created' | 'running' | 'completed' | 'failed' + error?: string + downloadError?: string + lastUpdate: number + downloadTriggered: boolean +} + +const STALE_THRESHOLD_MS = 10_000 +const POLL_INTERVAL_MS = 10_000 + +export const useAssetExportStore = defineStore('assetExport', () => { + const exports = ref>(new Map()) + + const exportList = computed(() => Array.from(exports.value.values())) + const activeExports = computed(() => + exportList.value.filter( + (e) => e.status === 'created' || e.status === 'running' + ) + ) + const finishedExports = computed(() => + exportList.value.filter( + (e) => e.status === 'completed' || e.status === 'failed' + ) + ) + const hasActiveExports = computed(() => activeExports.value.length > 0) + const hasExports = computed(() => exports.value.size > 0) + + function trackExport(taskId: string) { + if (exports.value.has(taskId)) return + + exports.value.set(taskId, { + taskId, + exportName: '', + assetsTotal: 0, + assetsAttempted: 0, + assetsFailed: 0, + bytesTotal: 0, + bytesProcessed: 0, + progress: 0, + status: 'created', + lastUpdate: Date.now(), + downloadTriggered: false + }) + } + + async function triggerDownload(exp: AssetExport, force = false) { + if (!force && (exp.downloadTriggered || !exp.exportName)) return + exp.downloadTriggered = true + + try { + exp.downloadError = undefined + const { url } = await assetService.getExportDownloadUrl(exp.exportName) + const link = document.createElement('a') + link.href = url + link.download = exp.exportName + link.style.display = 'none' + link.target = '_blank' + link.rel = 'noopener noreferrer' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + exp.downloadError = message + exp.downloadTriggered = false + + useToastStore().add({ + severity: 'error', + summary: t('exportToast.downloadFailed', { + name: exp.exportName + }), + detail: message + }) + } + } + + function handleAssetExport(data: AssetExportWsMessage) { + const existing = exports.value.get(data.task_id) + + if ( + (existing?.status === 'completed' || existing?.status === 'failed') && + existing?.downloadTriggered + ) { + return + } + + const exp: AssetExport = { + taskId: data.task_id, + exportName: data.export_name ?? existing?.exportName ?? '', + assetsTotal: data.assets_total, + assetsAttempted: data.assets_attempted, + assetsFailed: data.assets_failed, + bytesTotal: data.bytes_total, + bytesProcessed: data.bytes_processed, + progress: data.progress, + status: data.status, + error: data.error, + lastUpdate: Date.now(), + downloadTriggered: existing?.downloadTriggered ?? false + } + + exports.value.set(data.task_id, exp) + + if (data.status === 'completed') { + void triggerDownload(exp) + } + } + + async function pollStaleExports() { + const now = Date.now() + const staleExports = activeExports.value.filter( + (e) => now - e.lastUpdate >= STALE_THRESHOLD_MS + ) + + if (staleExports.length === 0) return + + async function pollSingleExport(exp: AssetExport) { + try { + const task = await taskService.getTask(exp.taskId) + + if (task.status === 'completed' || task.status === 'failed') { + const result = task.result as Record | undefined + handleAssetExport({ + task_id: exp.taskId, + export_name: (result?.export_name as string) ?? exp.exportName, + assets_total: (result?.assets_total as number) ?? exp.assetsTotal, + assets_attempted: + (result?.assets_attempted as number) ?? exp.assetsAttempted, + assets_failed: + (result?.assets_failed as number) ?? exp.assetsFailed, + bytes_total: exp.bytesTotal, + bytes_processed: exp.bytesTotal, + progress: task.status === 'completed' ? 1 : exp.progress, + status: task.status as 'completed' | 'failed', + error: task.error_message ?? (result?.error as string) + }) + } + } catch { + // Task not ready or not found + } + } + + await Promise.all(staleExports.map(pollSingleExport)) + } + + const { pause, resume } = useIntervalFn( + () => void pollStaleExports(), + POLL_INTERVAL_MS, + { immediate: false } + ) + + watch( + hasActiveExports, + (hasActive) => { + if (hasActive) resume() + else pause() + }, + { immediate: true } + ) + + api.addEventListener('asset_export', (e) => handleAssetExport(e.detail)) + + function clearFinishedExports() { + for (const exp of finishedExports.value) { + exports.value.delete(exp.taskId) + } + } + + return { + activeExports, + finishedExports, + hasActiveExports, + hasExports, + exportList, + trackExport, + triggerDownload, + clearFinishedExports + } +}) diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue index c5ef565de7..1764568b78 100644 --- a/src/views/GraphView.vue +++ b/src/views/GraphView.vue @@ -19,6 +19,7 @@ + @@ -54,6 +55,7 @@ import { useProgressFavicon } from '@/composables/useProgressFavicon' import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig' import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig' import { i18n, loadLocale } from '@/i18n' +import AssetExportProgressDialog from '@/platform/assets/components/AssetExportProgressDialog.vue' import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue' import { isCloud, isDesktop } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore'