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 @@
+
+
+
+
+
+
+
+ {{ t('exportToast.exportingAssets') }}
+
+
+
+
+
+
+
+
+ {{ jobDisplayName(job) }}
+
+
+ {{ job.assetsAttempted }}/{{ job.assetsTotal }}
+
+
+
+
+
+
+
+
+
+ {{ job.downloadError }}
+
+
+
+
+
+
+
+
+
+ {{ progressPercent(job) }}%
+
+
+
+
+ {{ t('progressToast.pending') }}
+
+
+
+
+
+
+
+
+ {{
+ t('exportToast.noExportsInQueue', {
+ filter: t('progressToast.filter.all')
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+ {{ footerLabel }}
+
+
+
+
+
+ {{
+ t('progressToast.progressCount', {
+ completed: completedCount,
+ total: totalCount
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
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