Compare commits

...

4 Commits

Author SHA1 Message Date
ShihChi Huang
3088754bb8 test: fix coverage run 2026-06-23 23:41:54 -07:00
Simon Pinfold
f076106ca5 fix: expand grouped assets when downloading multi-selection on OSS backend (#13079)
*PR Created by the Glary-Bot Agent*

---

## Summary

When using the ComfyUI (non-cloud) backend, selecting a grouped asset
(`outputCount > 1`) and clicking Download only downloaded the
cover/preview image instead of every output in the group.

The cloud backend already handles this correctly by exporting a ZIP
server-side. The OSS path called `downloadFile(asset.preview_url, ...)`
once per selected `AssetItem` and never expanded grouped assets into
their individual outputs.

## Fix

In `useMediaAssetActions.downloadAssets`, when any selected asset has
`outputCount > 1` and we're on the OSS path, resolve each grouped asset
into its individual outputs via the existing `resolveOutputAssetItems`
utility and trigger one direct download per file. Non-grouped selections
keep the original single-shot behaviour. After expansion the file list
is deduplicated by `AssetItem.id` so a user who selects both an expanded
stack parent and one of its children does not download the child twice.
The success toast now reflects the actual number of files downloaded.

- Single asset, single output → unchanged (1 download).
- Multi-select of single-output assets → unchanged (N downloads).
- Any selection containing a grouped asset → expanded via
`resolveOutputAssetItems` (same code path the cloud ZIP and
stack-expansion UI use). If resolution returns nothing, falls back to
the preview download so the user still gets something.
- Grouped parent + one of its expanded children selected → deduped, no
double download.

## Tests

Added unit tests in `useMediaAssetActions.test.ts` for the OSS path:

- Expands a grouped asset into individual downloads.
- Mixes grouped and single-output assets in one selection.
- Falls back to the original asset when `resolveOutputAssetItems`
returns empty.
- Does not call `resolveOutputAssetItems` when no grouped assets are
selected.
- Deduplicates downloads when an expanded child is also selected
alongside its parent.
- Shows an error toast when resolution rejects.

All 40 tests in the file pass; all 508 tests under `src/platform/assets`
pass. `pnpm typecheck`, `pnpm exec eslint`, `pnpm exec oxfmt --check`
all clean.

## Manual verification
Tested against a `master` ComfyUI instance with default settings.
Not tested against cloud - feature is gated to non cloud

Performed by @synap5e 
<img width="448" height="618" alt="image"
src="https://github.com/user-attachments/assets/daf32fa0-c5ec-47ca-bab3-d5ea3fb3d7cc"
/>


https://github.com/user-attachments/assets/a87ae1aa-836f-4cbc-9ef7-a35ed4f94ee7


https://github.com/user-attachments/assets/49d833bf-7b4e-4c53-b0d5-f16ff2108185

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-24 02:25:02 +00:00
Wei Hai
d7fa853c06 [chore] Update Comfy Registry API types from comfy-api@8d142eb (#13080)
Regenerates `packages/registry-types/src/comfyRegistryTypes.ts` from the
current comfy-api OpenAPI spec via `openapi-typescript`.

The FE registry types were last regenerated in May; this brings them up
to date. Notable addition consumed by #12924: the `createCustomer`
request body (`CreateCustomerRequest` with an optional
`turnstile_token`).

`pnpm typecheck` passes against the regenerated types.
2026-06-24 00:00:24 +00:00
CodeJuggernaut
07f881fc14 feat: float the Media Assets bulk-selection bar (#13043)
## Summary

Reskins the Media Assets bulk-selection bar into a prominent floating
pill so bulk-download actions are no longer easy to miss (Linear
FE-989).

## Changes

- **What**: The selection bar now floats over the bottom of the panel as
an inverted rounded pill — close · "{count} selected" · download ·
divider · delete — matching the Figma/prototype spacing, radius, and
shadow. Actions are icon-only with `v-tooltip` hints; the count uses
`tabular-nums` and reflects selected assets (not total outputs).
Extracted into a presentational `MediaAssetSelectionBar.vue` with a
Storybook story, a unit test, and an e2e guard that the count is
per-asset.

## Review Focus

- The pill floats via `position: absolute` (centered, `bottom-6`,
`z-40`) in the sidebar footer slot and overlays the bottom of the grid
by design.
- Count semantics changed from total outputs to selected-asset count
(`selectedAssets.length`).
- Out of scope, deferred per FE-989: marquee / select-all, pagination,
and the favorites / tags / label controls.

## Screenshots (if applicable)

<img width="1003" height="1767" alt="image"
src="https://github.com/user-attachments/assets/3a9ef884-e7f4-4d0b-a495-194ce0860db2"
/>

<img width="643" height="581" alt="image"
src="https://github.com/user-attachments/assets/1161884f-a9c2-4a2b-a20e-33ee3f189935"
/>

<img width="664" height="222" alt="image"
src="https://github.com/user-attachments/assets/b16b083c-bfd9-452d-b508-86b3cbfa9842"
/>

<img width="649" height="265" alt="image"
src="https://github.com/user-attachments/assets/a1076e34-58c9-4e7f-89c4-b21bb3281883"
/>
 
<img width="559" height="205" alt="image"
src="https://github.com/user-attachments/assets/09c24140-33ce-4629-b681-233c59916043"
/>

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-23 22:53:46 +00:00
13 changed files with 5861 additions and 246 deletions

View File

@@ -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'

View File

@@ -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 })
})

View File

@@ -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()
})

File diff suppressed because it is too large Load Diff

View File

@@ -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
)

View File

@@ -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)
}
},

View File

@@ -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', () => ({}))

View File

@@ -3341,7 +3341,7 @@
"error": "Error"
},
"selection": {
"selectedCount": "Assets Selected: {count}",
"selectedCount": "{count} selected",
"multipleSelectedAssets": "Multiple assets selected",
"deselectAll": "Deselect all",
"downloadSelected": "Download",

View File

@@ -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 }
}

View File

@@ -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()
})
})

View 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>

View File

@@ -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

View File

@@ -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()