mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-24 16:54:51 +00:00
Compare commits
4 Commits
update-ing
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3088754bb8 | ||
|
|
f076106ca5 | ||
|
|
d7fa853c06 | ||
|
|
07f881fc14 |
@@ -352,20 +352,11 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
this.downloadSelectedButton = page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.selectionFooter = page.getByTestId('assets-selection-bar')
|
||||
this.selectionCountButton = page.getByText(/\d+ selected/)
|
||||
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
|
||||
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
|
||||
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
|
||||
@@ -13,10 +13,6 @@ import type {
|
||||
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
|
||||
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
@@ -180,12 +176,10 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
@@ -194,11 +188,9 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
@@ -235,10 +227,8 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -286,11 +276,9 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -298,16 +286,13 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -342,10 +327,8 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
@@ -355,7 +338,6 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
|
||||
@@ -391,10 +373,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -405,11 +385,9 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
@@ -420,10 +398,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -431,15 +407,10 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible()
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
@@ -448,14 +419,11 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
@@ -481,10 +449,8 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
@@ -565,8 +531,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// job-gamma is the first card; its detail carries a valid workflow so
|
||||
// extraction succeeds and the filename prompt opens.
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
@@ -614,8 +578,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Strip the workflow field so extraction yields null and the export
|
||||
// action returns { success: false, error: 'No workflow…' }.
|
||||
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
|
||||
|
||||
@@ -625,7 +587,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Export workflow').click()
|
||||
|
||||
// Filename prompt should be skipped: extraction fails before the prompt.
|
||||
await expect(comfyPage.toast.toastWarnings).toBeVisible()
|
||||
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
|
||||
})
|
||||
@@ -639,23 +600,18 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
// useKeyModifier('Control') needs keyboard events, not click modifiers.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
// dispatchEvent avoids the selection footer intercepting a right click.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
@@ -664,7 +620,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -692,7 +647,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -704,7 +658,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -712,21 +665,67 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select the two single-output assets (job-alpha, job-beta).
|
||||
// The count reflects total outputs, not cards — job-gamma has
|
||||
// outputs_count: 2 which would inflate the total.
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection count sums the outputs of a stacked asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection bar stays capped, not stretched, on a wide panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1600, height: 900 })
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
const gutter = comfyPage.page.locator('.p-splitter-gutter').first()
|
||||
await expect(gutter).toBeVisible()
|
||||
const gutterBox = await gutter.boundingBox()
|
||||
if (!gutterBox) {
|
||||
throw new Error('sidebar splitter gutter has no bounding box')
|
||||
}
|
||||
await comfyPage.page.mouse.move(
|
||||
gutterBox.x + gutterBox.width / 2,
|
||||
gutterBox.y + gutterBox.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(900, gutterBox.y + gutterBox.height / 2, {
|
||||
steps: 12
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
|
||||
await expect
|
||||
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(520)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tab.selectionFooter.boundingBox()
|
||||
const side = await sidebar.boundingBox()
|
||||
return bar && side ? side.width - bar.width : 0
|
||||
})
|
||||
.toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -833,8 +832,7 @@ test.describe('Assets sidebar - pagination', () => {
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
// Queue polling also calls /jobs, so wait for completed history only.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
@@ -1002,9 +1000,7 @@ const MIXED_MEDIA_JOBS: RawJobListItem[] = [
|
||||
})
|
||||
]
|
||||
|
||||
// Filter button is guarded by isCloud (compile-time). The cloud CI project
|
||||
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
|
||||
// supports authenticated comfyPage setup.
|
||||
// Filter button is guarded by isCloud; cloud CI needs authenticated setup.
|
||||
test.describe('Assets sidebar - media type filter', () => {
|
||||
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
|
||||
|
||||
@@ -1040,12 +1036,9 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
'All three mixed-media jobs should render'
|
||||
).toHaveCount(3)
|
||||
|
||||
// Open filter menu and enable only image filter (selecting a filter
|
||||
// restricts to that type only, hiding unselected types)
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
|
||||
// Only the image asset should remain
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
|
||||
})
|
||||
@@ -1056,12 +1049,10 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Enable image filter to restrict to images only
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// Uncheck image filter to remove all filters (restores all assets)
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -214,7 +214,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.open()
|
||||
|
||||
await tab.getAssetCardByName('alpha').click()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b1 selected\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
@@ -222,7 +222,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.getAssetCardByName('beta').click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -115,69 +115,14 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
<MediaAssetSelectionBar
|
||||
v-if="hasSelection"
|
||||
ref="footerRef"
|
||||
class="flex h-18 w-full items-center justify-between gap-1"
|
||||
>
|
||||
<div class="flex-1 pl-4">
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<Button
|
||||
variant="secondary"
|
||||
:class="cn(isCompact && 'text-left')"
|
||||
@click="handleDeselectAll"
|
||||
>
|
||||
{{
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink items-center-safe justify-end-safe gap-2 pr-4">
|
||||
<template v-if="isCompact">
|
||||
<!-- Compact mode: Icon only -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Normal mode: Icon + Text -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
:count="totalOutputCount"
|
||||
:show-delete="shouldShowDeleteButton"
|
||||
@deselect="handleDeselectAll"
|
||||
@download="handleDownloadSelected"
|
||||
@delete="handleDeleteSelected"
|
||||
/>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<MediaLightbox
|
||||
@@ -208,8 +153,6 @@
|
||||
import {
|
||||
useAsyncState,
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
@@ -236,6 +179,7 @@ import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
@@ -257,7 +201,6 @@ import {
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
@@ -335,33 +278,6 @@ const {
|
||||
exportMultipleWorkflows
|
||||
} = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
const footerWidth = ref(0)
|
||||
|
||||
// Track footer width changes
|
||||
useResizeObserver(footerRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
footerWidth.value = entry.contentRect.width
|
||||
})
|
||||
|
||||
// Determine if we should show compact mode (icon only)
|
||||
// Threshold matches when grid switches from 2 columns to 1 column
|
||||
// 2 columns need about ~430px
|
||||
const COMPACT_MODE_THRESHOLD_PX = 430
|
||||
const isCompact = computed(
|
||||
() => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
|
||||
)
|
||||
|
||||
// Hover state for selection count button
|
||||
const selectionCountButtonRef = ref<HTMLElement | null>(null)
|
||||
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
return getTotalOutputCount(selectedAssets.value)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
activeTab.value === 'input' ? inputAssets : outputAssets
|
||||
)
|
||||
@@ -429,6 +345,10 @@ const previewableVisibleAssets = computed(() =>
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const totalOutputCount = computed(() =>
|
||||
getTotalOutputCount(selectedAssets.value)
|
||||
)
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
)
|
||||
|
||||
@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(workflow) => {
|
||||
if (
|
||||
workflow &&
|
||||
executionStore.getWorkflowStatus(workflow) !== 'running'
|
||||
) {
|
||||
() => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
|
||||
},
|
||||
([workflow, status]) => {
|
||||
if (workflow && status !== undefined && status !== 'running') {
|
||||
executionStore.clearWorkflowStatus(workflow)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
|
||||
@@ -3341,7 +3341,7 @@
|
||||
"error": "Error"
|
||||
},
|
||||
"selection": {
|
||||
"selectedCount": "Assets Selected: {count}",
|
||||
"selectedCount": "{count} selected",
|
||||
"multipleSelectedAssets": "Multiple assets selected",
|
||||
"deselectAll": "Deselect all",
|
||||
"downloadSelected": "Download",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import MediaAssetSelectionBar from './MediaAssetSelectionBar.vue'
|
||||
|
||||
const meta: Meta<typeof MediaAssetSelectionBar> = {
|
||||
title: 'Platform/Assets/MediaAssetSelectionBar',
|
||||
component: MediaAssetSelectionBar,
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="bg-base-background" style="position:relative;display:flex;flex-direction:column;justify-content:flex-end;height:480px;width:360px;"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { count: 2, showDelete: true }
|
||||
}
|
||||
|
||||
export const SingleSelection: Story = {
|
||||
args: { count: 1, showDelete: true }
|
||||
}
|
||||
|
||||
export const WithoutDelete: Story = {
|
||||
args: { count: 3, showDelete: false }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
mediaAsset: {
|
||||
selection: {
|
||||
deselectAll: 'Deselect all',
|
||||
downloadSelected: 'Download',
|
||||
deleteSelected: 'Delete',
|
||||
selectedCount: '{count} selected'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderBar(
|
||||
props: { count: number; showDelete?: boolean } = { count: 2 }
|
||||
) {
|
||||
return render(MediaAssetSelectionBar, {
|
||||
props,
|
||||
global: { plugins: [i18n], directives: { tooltip: {} } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('MediaAssetSelectionBar', () => {
|
||||
it('renders the selected count label', () => {
|
||||
renderBar({ count: 3 })
|
||||
expect(screen.getByText('3 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits deselect when the close button is clicked', async () => {
|
||||
const { emitted } = renderBar({ count: 2 })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Deselect all' }))
|
||||
expect(emitted().deselect).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits download when the download button is clicked', async () => {
|
||||
const { emitted } = renderBar({ count: 2 })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Download' }))
|
||||
expect(emitted().download).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits delete when the delete button is clicked', async () => {
|
||||
const { emitted } = renderBar({ count: 2 })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(emitted().delete).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides the delete button when showDelete is false', () => {
|
||||
renderBar({ count: 2, showDelete: false })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Delete' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
77
src/platform/assets/components/MediaAssetSelectionBar.vue
Normal file
77
src/platform/assets/components/MediaAssetSelectionBar.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
data-testid="assets-selection-bar"
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deselectAll'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-deselect-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deselectAll')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { count, showDelete = true } = defineProps<{
|
||||
count: number
|
||||
showDelete?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deselect: []
|
||||
download: []
|
||||
delete: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -10,6 +10,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import type * as outputAssetUtilModule from '../utils/outputAssetUtil'
|
||||
import { useMediaAssetActions } from './useMediaAssetActions'
|
||||
|
||||
// Use vi.hoisted to create a mutable reference for isCloud
|
||||
@@ -145,6 +146,17 @@ vi.mock('../schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: mockGetOutputAssetMetadata
|
||||
}))
|
||||
|
||||
const mockResolveOutputAssetItems = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue([])
|
||||
)
|
||||
vi.mock('../utils/outputAssetUtil', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof outputAssetUtilModule>()
|
||||
return {
|
||||
...actual,
|
||||
resolveOutputAssetItems: mockResolveOutputAssetItems
|
||||
}
|
||||
})
|
||||
|
||||
const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
||||
const mockCreateAssetExport = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
|
||||
@@ -283,6 +295,8 @@ describe('useMediaAssetActions', () => {
|
||||
mockGetOutputAssetMetadata.mockReset()
|
||||
mockGetOutputAssetMetadata.mockReturnValue(null)
|
||||
mockGetAssetType.mockReset()
|
||||
mockResolveOutputAssetItems.mockReset()
|
||||
mockResolveOutputAssetItems.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('addWorkflow', () => {
|
||||
@@ -582,6 +596,236 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadAssets - OSS multi-output expansion', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = false
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockImplementation(
|
||||
(meta: Record<string, unknown> | undefined) =>
|
||||
meta && 'jobId' in meta ? meta : null
|
||||
)
|
||||
})
|
||||
|
||||
function createOutputAsset(
|
||||
id: string,
|
||||
name: string,
|
||||
jobId: string,
|
||||
outputCount?: number,
|
||||
previewUrl?: string
|
||||
): AssetItem {
|
||||
return createMockAsset({
|
||||
id,
|
||||
name,
|
||||
tags: ['output'],
|
||||
preview_url: previewUrl ?? `https://example.com/${name}`,
|
||||
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
|
||||
})
|
||||
}
|
||||
|
||||
it('expands a grouped asset into individual downloads', async () => {
|
||||
const grouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover.png'
|
||||
)
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([
|
||||
createOutputAsset('g1-out1', 'out1.png', 'job1'),
|
||||
createOutputAsset('g1-out2', 'out2.png', 'job1'),
|
||||
createOutputAsset('g1-out3', 'out3.png', 'job1')
|
||||
])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job1', outputCount: 3 }),
|
||||
expect.objectContaining({ createdAt: expect.any(String) })
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://example.com/out1.png',
|
||||
'out1.png'
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://example.com/out2.png',
|
||||
'out2.png'
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://example.com/out3.png',
|
||||
'out3.png'
|
||||
)
|
||||
expect(mockCreateAssetExport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mixes grouped and single-output assets in one selection', async () => {
|
||||
const grouped = createOutputAsset('g1', 'cover.png', 'job1', 2)
|
||||
const single = createOutputAsset('s1', 'solo.png', 'job2')
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([
|
||||
createOutputAsset('g1-a', 'a.png', 'job1'),
|
||||
createOutputAsset('g1-b', 'b.png', 'job1')
|
||||
])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped, single])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
|
||||
expect(filenames).toEqual(['a.png', 'b.png', 'solo.png'])
|
||||
})
|
||||
|
||||
it('falls back to the original asset when resolveOutputAssetItems returns empty', async () => {
|
||||
const grouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover.png'
|
||||
)
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/cover.png',
|
||||
'cover.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call resolveOutputAssetItems when no grouped assets are selected', () => {
|
||||
const single1 = createOutputAsset(
|
||||
's1',
|
||||
'a.png',
|
||||
'job1',
|
||||
undefined,
|
||||
'https://example.com/a.png'
|
||||
)
|
||||
const single2 = createOutputAsset(
|
||||
's2',
|
||||
'b.png',
|
||||
'job2',
|
||||
1,
|
||||
'https://example.com/b.png'
|
||||
)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([single1, single2])
|
||||
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deduplicates downloads when an expanded child is also selected alongside its parent', async () => {
|
||||
const grouped = createOutputAsset('job1-cover', 'cover.png', 'job1', 3)
|
||||
const child = createMockAsset({
|
||||
id: 'job1-child-a',
|
||||
name: 'out1.png',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/out1.png',
|
||||
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
|
||||
})
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([
|
||||
createMockAsset({
|
||||
id: 'job1-child-a',
|
||||
name: 'out1.png',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/out1.png',
|
||||
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
|
||||
}),
|
||||
createMockAsset({
|
||||
id: 'job1-child-b',
|
||||
name: 'out2.png',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/out2.png',
|
||||
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
|
||||
})
|
||||
])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped, child])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
|
||||
expect(filenames).toEqual(['out1.png', 'out2.png'])
|
||||
})
|
||||
|
||||
it('falls back to the preview download when resolveOutputAssetItems rejects', async () => {
|
||||
const grouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover.png'
|
||||
)
|
||||
mockResolveOutputAssetItems.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/cover.png',
|
||||
'cover.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('still downloads resolvable assets when one grouped asset fails to expand', async () => {
|
||||
const failingGrouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover1.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover1.png'
|
||||
)
|
||||
const okGrouped = createOutputAsset('g2', 'cover2.png', 'job2', 2)
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(
|
||||
(metadata: { jobId: string }) => {
|
||||
if (metadata.jobId === 'job1') {
|
||||
return Promise.reject(new Error('job1 lookup failed'))
|
||||
}
|
||||
return Promise.resolve([
|
||||
createOutputAsset('g2-a', 'out2a.png', 'job2'),
|
||||
createOutputAsset('g2-b', 'out2b.png', 'job2')
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([failingGrouped, okGrouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
|
||||
expect(filenames).toEqual(['cover1.png', 'out2a.png', 'out2b.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadAssets - cloud zip filters', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
|
||||
@@ -27,7 +27,10 @@ import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
|
||||
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
|
||||
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
|
||||
import { getAssetOutputCount } from '../utils/outputAssetUtil'
|
||||
import {
|
||||
getAssetOutputCount,
|
||||
resolveOutputAssetItems
|
||||
} from '../utils/outputAssetUtil'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { isResultItemType } from '@/utils/typeGuardUtil'
|
||||
@@ -109,8 +112,9 @@ export function useMediaAssetActions() {
|
||||
* Download one or more assets.
|
||||
* In cloud mode, creates a ZIP export via the backend when called with
|
||||
* 2+ assets or with any asset whose job has `outputCount > 1`.
|
||||
* Falls back to direct downloads in OSS mode and for single single-output
|
||||
* assets. With no argument, uses the asset from `MediaAssetKey` context.
|
||||
* In OSS mode, downloads each file directly, expanding grouped assets
|
||||
* (`outputCount > 1`) into their individual outputs.
|
||||
* With no argument, uses the asset from `MediaAssetKey` context.
|
||||
*/
|
||||
const downloadAssets = (assets?: AssetItem[]) => {
|
||||
const targetAssets =
|
||||
@@ -127,13 +131,13 @@ export function useMediaAssetActions() {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
targetAssets.forEach((asset) => {
|
||||
const filename = getAssetDisplayName(asset)
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
if (hasMultiOutputJobs) {
|
||||
void downloadAssetsIndividually(targetAssets)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
targetAssets.forEach((asset) => downloadSingleAsset(asset))
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
@@ -150,6 +154,66 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSingleAsset(asset: AssetItem) {
|
||||
const filename = getAssetDisplayName(asset)
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
}
|
||||
|
||||
async function expandAssetForDownload(
|
||||
asset: AssetItem
|
||||
): Promise<AssetItem[]> {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (
|
||||
!metadata ||
|
||||
typeof metadata.outputCount !== 'number' ||
|
||||
metadata.outputCount <= 1
|
||||
) {
|
||||
return [asset]
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveOutputAssetItems(metadata, {
|
||||
createdAt: asset.created_at
|
||||
})
|
||||
return resolved.length > 0 ? resolved : [asset]
|
||||
} catch (error) {
|
||||
console.error('Failed to expand grouped asset for download:', error)
|
||||
return [asset]
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAssetsIndividually(assets: AssetItem[]) {
|
||||
try {
|
||||
const expanded = await Promise.all(assets.map(expandAssetForDownload))
|
||||
const seenAssetIds = new Set<string>()
|
||||
const filesToDownload = expanded.flat().filter((asset) => {
|
||||
if (seenAssetIds.has(asset.id)) return false
|
||||
seenAssetIds.add(asset.id)
|
||||
return true
|
||||
})
|
||||
|
||||
filesToDownload.forEach((asset) => downloadSingleAsset(asset))
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t(
|
||||
'mediaAsset.selection.downloadsStarted',
|
||||
filesToDownload.length
|
||||
),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to download assets:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAssetsAsZip(assets: AssetItem[]) {
|
||||
const assetExportStore = useAssetExportStore()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user