Files
ComfyUI_frontend/src/stores/assetExportStore.ts
Alexander Brown 8099cce232 feat: bulk asset export with ZIP download (#8712)
## Summary

Adds bulk asset export with ZIP download for cloud users. When selecting
2+ assets and clicking download, the frontend now requests a server-side
ZIP export instead of triggering individual file downloads.

## Changes

### New files
- **`AssetExportProgressDialog.vue`** — HoneyToast-based progress dialog
showing per-job export status with progress percentages, error
indicators, and a manual re-download button for completed exports
- **`assetExportStore.ts`** — Pinia store that tracks export jobs,
handles `asset_export` WebSocket events for real-time progress, polls
stale exports via the task API as a fallback, and auto-triggers ZIP
download on completion

### Modified files
- **`useMediaAssetActions.ts`** — `downloadMultipleAssets` now routes to
ZIP export (via `createAssetExport`) in cloud mode when 2+ assets are
selected; single assets and OSS mode still use direct download
- **`assetService.ts`** — Added `createAssetExport()` and
`getExportDownloadUrl()` endpoints
- **`apiSchema.ts`** — Added `AssetExportWsMessage` type for the
WebSocket event
- **`api.ts`** — Wired up `asset_export` WebSocket event
- **`GraphView.vue`** — Mounted `AssetExportProgressDialog`
- **`main.json`** — Added i18n keys for export toast UI

## How it works

1. User selects multiple assets and clicks download
2. Frontend calls `POST /assets/export` with asset/job IDs
3. Backend creates a ZIP task and streams progress via `asset_export`
WebSocket events
4. `AssetExportProgressDialog` shows real-time progress
5. On completion, the ZIP is auto-downloaded via a presigned URL from
`GET /assets/exports/{name}`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8712-feat-bulk-asset-export-with-ZIP-download-3006d73d365081839ec3dd3e7b0d3b77)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-18 16:36:59 -08:00

200 lines
5.6 KiB
TypeScript

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<Map<string, AssetExport>>(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<string, unknown> | 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
}
})