diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 5fd466cda..0ae5e35aa 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -228,9 +228,9 @@ import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAs import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema' +import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil' import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' -import { getJobDetail } from '@/services/jobOutputCache' import { useCommandStore } from '@/stores/commandStore' import { useDialogStore } from '@/stores/dialogStore' import { useExecutionStore } from '@/stores/executionStore' @@ -238,12 +238,6 @@ import { ResultItemImpl, useQueueStore } from '@/stores/queueStore' import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil' import { cn } from '@/utils/tailwindUtil' -interface JobOutputItem { - filename: string - subfolder: string - type: string -} - const { t, n } = useI18n() const commandStore = useCommandStore() const queueStore = useQueueStore() @@ -550,7 +544,7 @@ const enterFolderView = async (asset: AssetItem) => { return } - const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata + const { promptId, executionTimeInSeconds } = metadata if (!promptId) { console.warn('Missing required folder view data') @@ -560,62 +554,16 @@ const enterFolderView = async (asset: AssetItem) => { folderPromptId.value = promptId folderExecutionTime.value = executionTimeInSeconds - // Determine which outputs to display - let outputsToDisplay = allOutputs ?? [] + const folderItems = await resolveOutputAssetItems(metadata, { + createdAt: asset.created_at + }) - // If outputCount indicates more outputs than we have, fetch full outputs - const needsFullOutputs = - typeof outputCount === 'number' && - outputCount > 1 && - outputsToDisplay.length < outputCount - - if (needsFullOutputs) { - try { - const jobDetail = await getJobDetail(promptId) - if (jobDetail?.outputs) { - // Convert job outputs to ResultItemImpl array - outputsToDisplay = Object.entries(jobDetail.outputs).flatMap( - ([nodeId, nodeOutputs]) => - Object.entries(nodeOutputs).flatMap(([mediaType, items]) => - (items as JobOutputItem[]) - .map( - (item) => - new ResultItemImpl({ - ...item, - nodeId, - mediaType - }) - ) - .filter((r) => r.supportsPreview) - ) - ) - } - } catch (error) { - console.error('Failed to fetch job detail for folder view:', error) - outputsToDisplay = [] - } - } - - if (outputsToDisplay.length === 0) { + if (folderItems.length === 0) { console.warn('No outputs available for folder view') return } - folderAssets.value = outputsToDisplay.map((output) => ({ - id: `${output.nodeId}-${output.filename}`, - name: output.filename, - size: 0, - created_at: asset.created_at, - tags: ['output'], - preview_url: output.url, - user_metadata: { - promptId, - nodeId: output.nodeId, - subfolder: output.subfolder, - executionTimeInSeconds, - workflow: metadata.workflow - } - })) + folderAssets.value = folderItems } const exitFolderView = () => { diff --git a/src/platform/assets/utils/outputAssetUtil.test.ts b/src/platform/assets/utils/outputAssetUtil.test.ts new file mode 100644 index 000000000..41779958b --- /dev/null +++ b/src/platform/assets/utils/outputAssetUtil.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' +import type { ResultItemImpl } from '@/stores/queueStore' + +import { resolveOutputAssetItems } from './outputAssetUtil' + +const mocks = vi.hoisted(() => ({ + getJobDetail: vi.fn(), + getPreviewableOutputsFromJobDetail: vi.fn() +})) + +vi.mock('@/services/jobOutputCache', () => ({ + getJobDetail: mocks.getJobDetail, + getPreviewableOutputsFromJobDetail: mocks.getPreviewableOutputsFromJobDetail +})) + +type OutputOverrides = Partial<{ + filename: string + subfolder: string + nodeId: string + url: string +}> + +function createOutput(overrides: OutputOverrides = {}): ResultItemImpl { + return { + filename: 'file.png', + subfolder: 'sub', + nodeId: '1', + url: 'https://example.com/file.png', + ...overrides + } as ResultItemImpl +} + +describe('resolveOutputAssetItems', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('maps outputs and excludes a filename', async () => { + const outputA = createOutput({ + filename: 'a.png', + nodeId: '1', + url: 'https://example.com/a.png' + }) + const outputB = createOutput({ + filename: 'b.png', + nodeId: '2', + url: 'https://example.com/b.png' + }) + const metadata: OutputAssetMetadata = { + promptId: 'prompt-1', + nodeId: '1', + subfolder: 'sub', + executionTimeInSeconds: 12.5, + outputCount: 2, + allOutputs: [outputA, outputB] + } + + const results = await resolveOutputAssetItems(metadata, { + createdAt: '2025-01-01T00:00:00.000Z', + excludeOutputKey: 'b.png' + }) + + expect(mocks.getJobDetail).not.toHaveBeenCalled() + expect(results).toHaveLength(1) + expect(results[0]).toEqual( + expect.objectContaining({ + id: 'prompt-1-1-a.png', + name: 'a.png', + created_at: '2025-01-01T00:00:00.000Z', + tags: ['output'], + preview_url: 'https://example.com/a.png' + }) + ) + expect(results[0].user_metadata).toEqual( + expect.objectContaining({ + promptId: 'prompt-1', + nodeId: '1', + subfolder: 'sub', + executionTimeInSeconds: 12.5 + }) + ) + }) + + it('loads full outputs when metadata indicates more outputs', async () => { + const previewOutput = createOutput({ + filename: 'preview.png', + nodeId: '1', + url: 'https://example.com/preview.png' + }) + const fullOutput = createOutput({ + filename: 'full.png', + nodeId: '2', + url: 'https://example.com/full.png' + }) + const metadata: OutputAssetMetadata = { + promptId: 'prompt-2', + nodeId: '1', + subfolder: 'sub', + outputCount: 3, + allOutputs: [previewOutput] + } + const jobDetail = { id: 'job-1' } + + mocks.getJobDetail.mockResolvedValue(jobDetail) + mocks.getPreviewableOutputsFromJobDetail.mockReturnValue([ + fullOutput, + previewOutput + ]) + + const results = await resolveOutputAssetItems(metadata) + + expect(mocks.getJobDetail).toHaveBeenCalledWith('prompt-2') + expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith( + jobDetail + ) + expect(results.map((asset) => asset.name)).toEqual([ + 'full.png', + 'preview.png' + ]) + }) +}) diff --git a/src/platform/assets/utils/outputAssetUtil.ts b/src/platform/assets/utils/outputAssetUtil.ts new file mode 100644 index 000000000..f0fbc1bce --- /dev/null +++ b/src/platform/assets/utils/outputAssetUtil.ts @@ -0,0 +1,84 @@ +import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import { + getJobDetail, + getPreviewableOutputsFromJobDetail +} from '@/services/jobOutputCache' +import type { ResultItemImpl } from '@/stores/queueStore' + +type OutputAssetMapOptions = { + promptId: string + outputs: readonly ResultItemImpl[] + createdAt?: string + executionTimeInSeconds?: number + workflow?: OutputAssetMetadata['workflow'] + excludeFilename?: string +} + +type ResolveOutputAssetItemsOptions = { + createdAt?: string + excludeOutputKey?: string +} + +function shouldLoadFullOutputs( + outputCount: OutputAssetMetadata['outputCount'], + outputsLength: number +): boolean { + return ( + typeof outputCount === 'number' && + outputCount > 1 && + outputsLength < outputCount + ) +} + +function mapOutputsToAssetItems({ + promptId, + outputs, + createdAt, + executionTimeInSeconds, + workflow, + excludeFilename +}: OutputAssetMapOptions): AssetItem[] { + const createdAtValue = createdAt ?? new Date().toISOString() + + return outputs + .filter((output) => output.filename && output.filename !== excludeFilename) + .map((output) => ({ + id: `${promptId}-${output.nodeId}-${output.filename}`, + name: output.filename, + size: 0, + created_at: createdAtValue, + tags: ['output'], + preview_url: output.url, + user_metadata: { + promptId, + nodeId: output.nodeId, + subfolder: output.subfolder, + executionTimeInSeconds, + workflow + } + })) +} + +export async function resolveOutputAssetItems( + metadata: OutputAssetMetadata, + { createdAt, excludeOutputKey }: ResolveOutputAssetItemsOptions = {} +): Promise { + let outputsToDisplay = metadata.allOutputs ?? [] + if (shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) { + const jobDetail = await getJobDetail(metadata.promptId) + const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail) + if (previewableOutputs.length) { + outputsToDisplay = previewableOutputs + } + } + + return mapOutputsToAssetItems({ + promptId: metadata.promptId, + outputs: outputsToDisplay, + createdAt, + executionTimeInSeconds: metadata.executionTimeInSeconds, + workflow: metadata.workflow, + excludeFilename: excludeOutputKey + }) +}