mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-26 01:27:23 +00:00
Compare commits
9 Commits
DynamicGro
...
synap5e/fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782a8d6923 | ||
|
|
3789fbfeb8 | ||
|
|
65de230fff | ||
|
|
6287837ff5 | ||
|
|
eb18c85bb5 | ||
|
|
3d3fdc50b7 | ||
|
|
2aff99d307 | ||
|
|
4785682953 | ||
|
|
c10d21819c |
@@ -318,6 +318,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
public readonly deselectAllButton: Locator
|
||||
public readonly deleteSelectedButton: Locator
|
||||
public readonly downloadSelectedButton: Locator
|
||||
public readonly includePreviewsToggle: Locator
|
||||
|
||||
// --- Folder view ---
|
||||
public readonly backToAssetsButton: Locator
|
||||
@@ -366,6 +367,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.includePreviewsToggle = page.getByTestId('assets-include-previews')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
|
||||
@@ -751,6 +751,32 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
expect('include_previews' in payload).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
cloudTest(
|
||||
'Download with previews sets include_previews on the export request',
|
||||
async ({ comfyPage, mockCloudAssetSidebarData }) => {
|
||||
void mockCloudAssetSidebarData
|
||||
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.includePreviewsToggle).toBeVisible()
|
||||
|
||||
await tab.includePreviewsToggle.click()
|
||||
await tab.downloadSelectedButton.click()
|
||||
|
||||
await expect.poll(() => exportRequests).toHaveLength(1)
|
||||
|
||||
const payload = exportRequests[0]
|
||||
expect(payload.job_ids).toEqual(['job-gamma'])
|
||||
expect(
|
||||
'include_previews' in payload ? payload.include_previews : false
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
202
src/components/sidebar/tabs/AssetsSidebarTab.test.ts
Normal file
202
src/components/sidebar/tabs/AssetsSidebarTab.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import AssetsSidebarTab from './AssetsSidebarTab.vue'
|
||||
|
||||
const mockDownloadAssets = vi.hoisted(() => vi.fn())
|
||||
|
||||
const outputAsset: AssetItem = {
|
||||
id: 'out-1',
|
||||
name: 'render.png',
|
||||
size: 1024,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
tags: ['output']
|
||||
}
|
||||
|
||||
function createAssetsApi() {
|
||||
return {
|
||||
media: ref<AssetItem[]>([outputAsset]),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
fetchMediaList: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
loadMore: vi.fn(),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
|
||||
useAssetsApi: () => createAssetsApi()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useMediaAssetFiltering', () => ({
|
||||
useMediaAssetFiltering: (assets: { value: AssetItem[] }) => ({
|
||||
searchQuery: ref(''),
|
||||
sortBy: ref('newest'),
|
||||
mediaTypeFilters: ref([]),
|
||||
filteredAssets: computed(() => assets.value)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useOutputStacks', () => ({
|
||||
useOutputStacks: () => ({
|
||||
assetItems: ref([]),
|
||||
selectableAssets: ref([]),
|
||||
isStackExpanded: () => false,
|
||||
toggleStack: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useAssetSelection', () => ({
|
||||
useAssetSelection: () => ({
|
||||
isSelected: () => false,
|
||||
handleAssetClick: vi.fn(),
|
||||
hasSelection: ref(true),
|
||||
clearSelection: vi.fn(),
|
||||
getSelectedAssets: (assets: AssetItem[]) => assets,
|
||||
reconcileSelection: vi.fn(),
|
||||
getOutputCount: () => 1,
|
||||
getTotalOutputCount: () => 1,
|
||||
activate: vi.fn(),
|
||||
deactivate: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
|
||||
useMediaAssetActions: () => ({
|
||||
downloadAssets: mockDownloadAssets,
|
||||
deleteAssets: vi.fn().mockResolvedValue(false),
|
||||
addMultipleToWorkflow: vi.fn(),
|
||||
openMultipleWorkflows: vi.fn(),
|
||||
exportMultipleWorkflows: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
mediaAssets: { title: 'Media Assets' },
|
||||
backToAssets: 'Back to all assets',
|
||||
labels: { generated: 'Generated', imported: 'Imported' },
|
||||
noImportedFiles: 'No imported files',
|
||||
noGeneratedFiles: 'No generated files',
|
||||
noFilesFoundMessage: 'No files found'
|
||||
},
|
||||
assetBrowser: { jobId: 'Job ID' },
|
||||
mediaAsset: {
|
||||
selection: {
|
||||
includePreviews: 'Download previews',
|
||||
downloadSelected: 'Download',
|
||||
deleteSelected: 'Delete',
|
||||
deselectAll: 'Deselect all',
|
||||
selectedCount: '{count} selected'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderTab() {
|
||||
return render(AssetsSidebarTab, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ stubActions: false }), i18n],
|
||||
directives: { tooltip: () => {} },
|
||||
stubs: {
|
||||
SidebarTabTemplate: {
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /><slot name="footer" /></div>'
|
||||
},
|
||||
MediaAssetFilterBar: true,
|
||||
AssetsSidebarGridView: true,
|
||||
AssetsSidebarListView: true,
|
||||
MediaAssetContextMenu: true,
|
||||
MediaLightbox: true,
|
||||
NoResultsPlaceholder: true,
|
||||
Skeleton: true,
|
||||
ToggleSwitch: {
|
||||
props: { modelValue: { type: Boolean, default: false } },
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button type="button" role="switch" :aria-checked="modelValue" @click="$emit(\'update:modelValue\', !modelValue)" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const includePreviewsToggle = () =>
|
||||
screen.queryByTestId('assets-include-previews')
|
||||
|
||||
describe('AssetsSidebarTab include-previews toggle', () => {
|
||||
beforeEach(() => {
|
||||
mockDownloadAssets.mockClear()
|
||||
})
|
||||
|
||||
it('shows the toggle on the generated (output) tab and hides it on imported', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTab()
|
||||
|
||||
expect(includePreviewsToggle()).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Imported' }))
|
||||
|
||||
expect(includePreviewsToggle()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('downloads without previews by default', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTab()
|
||||
|
||||
await user.click(screen.getByTestId('assets-download-selected'))
|
||||
|
||||
expect(mockDownloadAssets).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ id: 'out-1' })],
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards include-previews when the toggle is enabled before download', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTab()
|
||||
|
||||
const toggle = includePreviewsToggle()!
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
await user.click(screen.getByTestId('assets-download-selected'))
|
||||
|
||||
expect(mockDownloadAssets).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ id: 'out-1' })],
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not forward a stale toggle after switching away from the output tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTab()
|
||||
|
||||
await user.click(includePreviewsToggle()!)
|
||||
await user.click(screen.getByRole('tab', { name: 'Imported' }))
|
||||
await user.click(screen.getByTestId('assets-download-selected'))
|
||||
|
||||
expect(mockDownloadAssets).toHaveBeenCalledWith(expect.anything(), false)
|
||||
})
|
||||
})
|
||||
@@ -118,64 +118,68 @@
|
||||
<div
|
||||
v-if="hasSelection"
|
||||
ref="footerRef"
|
||||
class="flex h-18 w-full items-center justify-between gap-1"
|
||||
class="flex w-full flex-col justify-center gap-2 py-2"
|
||||
>
|
||||
<div class="flex-1 pl-4">
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<div class="flex 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"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:class="cn(isCompact && 'text-left')"
|
||||
@click="handleDeselectAll"
|
||||
v-if="shouldShowDeleteButton"
|
||||
:variant="isCompact ? undefined : 'secondary'"
|
||||
:size="isCompact ? 'icon' : undefined"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
{{
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
}}
|
||||
<span v-if="!isCompact">{{
|
||||
$t('mediaAsset.selection.deleteSelected')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
:variant="isCompact ? undefined : 'secondary'"
|
||||
:size="isCompact ? 'icon' : undefined"
|
||||
data-testid="assets-download-selected"
|
||||
@click="
|
||||
handleDownloadSelected(canIncludePreviews && includePreviews)
|
||||
"
|
||||
>
|
||||
<span v-if="!isCompact">{{
|
||||
$t('mediaAsset.selection.downloadSelected')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</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
|
||||
v-if="canIncludePreviews"
|
||||
class="flex items-center justify-end gap-2 pr-4"
|
||||
>
|
||||
<label for="assets-include-previews" class="text-sm">{{
|
||||
$t('mediaAsset.selection.includePreviews')
|
||||
}}</label>
|
||||
<ToggleSwitch
|
||||
v-model="includePreviews"
|
||||
input-id="assets-include-previews"
|
||||
data-testid="assets-include-previews"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -213,6 +217,7 @@ import {
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
computed,
|
||||
@@ -559,11 +564,14 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleDownloadSelected = () => {
|
||||
downloadAssets(selectedAssets.value)
|
||||
const handleDownloadSelected = (includePreviews = false) => {
|
||||
downloadAssets(selectedAssets.value, includePreviews)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const canIncludePreviews = computed(() => activeTab.value === 'output')
|
||||
const includePreviews = ref(false)
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (await deleteAssets(selectedAssets.value)) {
|
||||
clearSelection()
|
||||
|
||||
@@ -3220,6 +3220,7 @@
|
||||
"multipleSelectedAssets": "Multiple assets selected",
|
||||
"deselectAll": "Deselect all",
|
||||
"downloadSelected": "Download",
|
||||
"includePreviews": "Download previews",
|
||||
"downloadSelectedAll": "Download all",
|
||||
"deleteSelected": "Delete",
|
||||
"deleteSelectedAll": "Delete all",
|
||||
@@ -3229,6 +3230,7 @@
|
||||
"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",
|
||||
"exportStartedJobs": "Downloading assets from {count} job | Downloading assets from {count} jobs",
|
||||
"assetsDeletedSuccessfully": "{count} asset deleted successfully | {count} assets deleted successfully",
|
||||
"failedToDeleteAssets": "Failed to delete selected assets",
|
||||
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",
|
||||
|
||||
@@ -547,6 +547,35 @@ describe('useMediaAssetActions', () => {
|
||||
expect(mockTrackExport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces the ZIP export path for a single asset when previews are requested', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockReturnValue({
|
||||
jobId: 'job1',
|
||||
outputCount: 1
|
||||
})
|
||||
|
||||
const asset = createMockAsset({
|
||||
id: 'single-output',
|
||||
name: 'single-output.png',
|
||||
preview_url: 'https://example.com/single-output.png',
|
||||
tags: ['output'],
|
||||
user_metadata: { jobId: 'job1', outputCount: 1 }
|
||||
})
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([asset], true)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.include_previews).toBe(true)
|
||||
expect(payload.job_ids).toEqual(['job1'])
|
||||
})
|
||||
|
||||
it('uses ZIP export for an injected single multi-output asset in cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
@@ -698,6 +727,34 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
expect(payload.naming_strategy).toBe('preserve')
|
||||
})
|
||||
|
||||
it('omits include_previews by default', async () => {
|
||||
const assets = [createOutputAsset('a1', 'img1.png', 'job1', 3)]
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets(assets)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.include_previews).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sends include_previews when requested', async () => {
|
||||
const assets = [createOutputAsset('a1', 'img1.png', 'job1', 3)]
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets(assets, true)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.include_previews).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadAssets - export toast file count', () => {
|
||||
@@ -725,7 +782,7 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function expectExportToastFileCount(count: number) {
|
||||
async function expectExportToast(key: string, count: number) {
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -733,58 +790,53 @@ describe('useMediaAssetActions', () => {
|
||||
const { add } = useToast()
|
||||
await vi.waitFor(() => {
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: 'mediaAsset.selection.exportStarted'
|
||||
})
|
||||
expect.objectContaining({ detail: key })
|
||||
)
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
expect(t).toHaveBeenCalledWith(
|
||||
'mediaAsset.selection.exportStarted',
|
||||
{ count },
|
||||
count
|
||||
)
|
||||
expect(t).toHaveBeenCalledWith(key, { count }, count)
|
||||
}
|
||||
|
||||
it('should report total file count, not job count, for multi-output jobs', async () => {
|
||||
it('reports the job count for an output export without previews', async () => {
|
||||
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 2)
|
||||
const j2 = createOutputAsset('a2', 'img2.png', 'job2', 4)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([j1, j2])
|
||||
|
||||
await expectExportToastFileCount(6)
|
||||
await expectExportToast('mediaAsset.selection.exportStartedJobs', 2)
|
||||
})
|
||||
|
||||
it('should treat assets without outputCount as a single file', async () => {
|
||||
const a1 = createOutputAsset('a1', 'img1.png', 'job1')
|
||||
const a2 = createOutputAsset('a2', 'img2.png', 'job2')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([a1, a2])
|
||||
|
||||
await expectExportToastFileCount(2)
|
||||
})
|
||||
|
||||
it('should mix multi-output and single-output assets correctly', async () => {
|
||||
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 3)
|
||||
const a2 = createOutputAsset('a2', 'img2.png', 'job2')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([j1, a2])
|
||||
|
||||
await expectExportToastFileCount(4)
|
||||
})
|
||||
|
||||
it('should only count duplicate job-level output selections once', async () => {
|
||||
it('counts distinct jobs, not selected assets', async () => {
|
||||
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 3)
|
||||
const j1Duplicate = createOutputAsset('a2', 'img2.png', 'job1', 3)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([j1, j1Duplicate])
|
||||
|
||||
await expectExportToastFileCount(3)
|
||||
await expectExportToast('mediaAsset.selection.exportStartedJobs', 1)
|
||||
})
|
||||
|
||||
it('reports the preview-inclusive file count when exporting with previews', async () => {
|
||||
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 2)
|
||||
const j2 = createOutputAsset('a2', 'img2.png', 'job2', 4)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([j1, j2], true)
|
||||
|
||||
await expectExportToast('mediaAsset.selection.exportStarted', 6)
|
||||
})
|
||||
|
||||
it('reports the file count for a jobless (input) export without previews', async () => {
|
||||
mockGetAssetType.mockReturnValue('input')
|
||||
const a1 = createMockAsset({ id: 'a1', name: 'in1.png', tags: ['input'] })
|
||||
const a2 = createMockAsset({ id: 'a2', name: 'in2.png', tags: ['input'] })
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([a1, a2])
|
||||
|
||||
await expectExportToast('mediaAsset.selection.exportStarted', 2)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -105,11 +105,14 @@ 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`.
|
||||
* 2+ assets, with any asset whose job has `outputCount > 1`, or when
|
||||
* `includePreviews` is set. Previews are only retrievable via the export
|
||||
* endpoint, so requesting them must force the ZIP path even for a single
|
||||
* asset; the direct-download branch only fetches visible output URLs.
|
||||
* Falls back to direct downloads in OSS mode and for single single-output
|
||||
* assets. With no argument, uses the asset from `MediaAssetKey` context.
|
||||
*/
|
||||
const downloadAssets = (assets?: AssetItem[]) => {
|
||||
const downloadAssets = (assets?: AssetItem[], includePreviews = false) => {
|
||||
const targetAssets =
|
||||
assets ?? (mediaContext?.asset.value ? [mediaContext.asset.value] : [])
|
||||
if (targetAssets.length === 0) return
|
||||
@@ -119,8 +122,11 @@ export function useMediaAssetActions() {
|
||||
return typeof count === 'number' && count > 1
|
||||
})
|
||||
|
||||
if (isCloud && (targetAssets.length > 1 || hasMultiOutputJobs)) {
|
||||
void downloadAssetsAsZip(targetAssets)
|
||||
if (
|
||||
isCloud &&
|
||||
(includePreviews || targetAssets.length > 1 || hasMultiOutputJobs)
|
||||
) {
|
||||
void downloadAssetsAsZip(targetAssets, includePreviews)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -147,7 +153,10 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAssetsAsZip(assets: AssetItem[]) {
|
||||
async function downloadAssetsAsZip(
|
||||
assets: AssetItem[],
|
||||
includePreviews = false
|
||||
) {
|
||||
const assetExportStore = useAssetExportStore()
|
||||
|
||||
try {
|
||||
@@ -201,19 +210,33 @@ export function useMediaAssetActions() {
|
||||
...(Object.keys(jobAssetNameFilters).length > 0
|
||||
? { job_asset_name_filters: jobAssetNameFilters }
|
||||
: {}),
|
||||
...(includePreviews ? { include_previews: true } : {}),
|
||||
naming_strategy: namingStrategy
|
||||
})
|
||||
|
||||
assetExportStore.trackExport(result.task_id)
|
||||
|
||||
// For output exports the precise file count is unknown up front: the
|
||||
// job's output count includes preview/temp assets, which are only in
|
||||
// the archive when include_previews is set. Report the job count
|
||||
// instead so the number is accurate; with previews the count already
|
||||
// matches the archive.
|
||||
const useJobCount = !includePreviews && jobIds.length > 0
|
||||
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('exportToast.exportStarted'),
|
||||
detail: t(
|
||||
'mediaAsset.selection.exportStarted',
|
||||
{ count: fileCount },
|
||||
fileCount
|
||||
),
|
||||
detail: useJobCount
|
||||
? t(
|
||||
'mediaAsset.selection.exportStartedJobs',
|
||||
{ count: jobIds.length },
|
||||
jobIds.length
|
||||
)
|
||||
: t(
|
||||
'mediaAsset.selection.exportStarted',
|
||||
{ count: fileCount },
|
||||
fileCount
|
||||
),
|
||||
life: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -50,6 +50,7 @@ interface AssetExportOptions {
|
||||
| 'preserve'
|
||||
| 'asset_id'
|
||||
job_asset_name_filters?: Record<string, string[]>
|
||||
include_previews?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user