mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
8 Commits
fix/load-a
...
media/view
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2e2bfcbb | ||
|
|
a3a35aa93e | ||
|
|
7097918650 | ||
|
|
0fde4d7c50 | ||
|
|
a54365b1f0 | ||
|
|
3fc47e25f5 | ||
|
|
9900c2978c | ||
|
|
56905c75ab |
@@ -49,9 +49,13 @@
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
:stack-count="getStackCount(item.asset)"
|
||||
:primary-text="
|
||||
showAssetNames ? getAssetPrimaryText(item.asset) : undefined
|
||||
"
|
||||
:secondary-text="
|
||||
showAssetDetails ? getAssetSecondaryText(item.asset) : undefined
|
||||
"
|
||||
:stack-count="groupByJob ? getStackCount(item.asset) : undefined"
|
||||
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
|
||||
:stack-expanded="isStackExpanded(item.asset)"
|
||||
@mouseenter="onAssetEnter(item.asset.id)"
|
||||
@@ -106,7 +110,10 @@ const {
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack,
|
||||
assetType = 'output'
|
||||
assetType = 'output',
|
||||
groupByJob = true,
|
||||
showAssetNames = true,
|
||||
showAssetDetails = true
|
||||
} = defineProps<{
|
||||
assetItems: OutputStackListItem[]
|
||||
selectableAssets: AssetItem[]
|
||||
@@ -114,6 +121,9 @@ const {
|
||||
isStackExpanded: (asset: AssetItem) => boolean
|
||||
toggleStack: (asset: AssetItem) => Promise<void>
|
||||
assetType?: 'input' | 'output'
|
||||
groupByJob?: boolean
|
||||
showAssetNames?: boolean
|
||||
showAssetDetails?: boolean
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
v-model:group-by-job="groupByJob"
|
||||
v-model:show-asset-names="showAssetNames"
|
||||
v-model:show-asset-details="showAssetDetails"
|
||||
class="px-2 pb-1 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
@@ -88,12 +91,17 @@
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<AssetsSidebarListView
|
||||
v-if="isListView"
|
||||
:asset-items="listViewAssetItems"
|
||||
:asset-items="
|
||||
groupByJob ? listViewAssetItems : ungroupedListViewItems
|
||||
"
|
||||
:is-selected="isSelected"
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:selectable-assets="listViewVisibleAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
:asset-type="activeTab"
|
||||
:group-by-job="groupByJob"
|
||||
:show-asset-names="showAssetNames"
|
||||
:show-asset-details="showAssetDetails"
|
||||
@select-asset="handleAssetSelect"
|
||||
@preview-asset="handleZoomClick"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@@ -101,7 +109,7 @@
|
||||
/>
|
||||
<AssetsSidebarGridView
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:assets="ungroupedAssets"
|
||||
:is-selected="isSelected"
|
||||
:asset-type="activeTab"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
@@ -211,6 +219,7 @@ import {
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
@@ -232,11 +241,17 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { useUngroupedAssets } from '@/platform/assets/composables/useUngroupedAssets'
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import {
|
||||
ShowAssetDetailsKey,
|
||||
ShowAssetNamesKey
|
||||
} from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -266,6 +281,14 @@ const viewMode = useStorage<'list' | 'grid'>(
|
||||
'grid'
|
||||
)
|
||||
const isListView = computed(() => viewMode.value === 'list')
|
||||
const groupByJob = useStorage('Comfy.Assets.Sidebar.GroupByJob', true)
|
||||
const showAssetNames = useStorage('Comfy.Assets.Sidebar.ShowAssetNames', true)
|
||||
const showAssetDetails = useStorage(
|
||||
'Comfy.Assets.Sidebar.ShowAssetDetails',
|
||||
true
|
||||
)
|
||||
provide(ShowAssetNamesKey, showAssetNames)
|
||||
provide(ShowAssetDetailsKey, showAssetDetails)
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
@@ -286,6 +309,7 @@ const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (!groupByJob.value) return false
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
}
|
||||
@@ -392,9 +416,10 @@ const baseAssets = computed(() => {
|
||||
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
|
||||
useMediaAssetFiltering(baseAssets)
|
||||
|
||||
const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
})
|
||||
const { ungroupedAssets, isResolving } = useUngroupedAssets(
|
||||
filteredAssets,
|
||||
groupByJob
|
||||
)
|
||||
|
||||
const {
|
||||
assetItems: listViewAssetItems,
|
||||
@@ -402,11 +427,22 @@ const {
|
||||
isStackExpanded: isListViewStackExpanded,
|
||||
toggleStack: toggleListViewStack
|
||||
} = useOutputStacks({
|
||||
assets: computed(() => displayAssets.value)
|
||||
assets: filteredAssets
|
||||
})
|
||||
|
||||
const ungroupedListViewItems = computed<OutputStackListItem[]>(() =>
|
||||
ungroupedAssets.value.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
}))
|
||||
)
|
||||
|
||||
const listViewVisibleAssets = computed(() =>
|
||||
groupByJob.value ? listViewSelectableAssets.value : ungroupedAssets.value
|
||||
)
|
||||
|
||||
const visibleAssets = computed(() => {
|
||||
if (!isListView.value) return displayAssets.value
|
||||
if (!isListView.value || !groupByJob.value) return ungroupedAssets.value
|
||||
return listViewSelectableAssets.value
|
||||
})
|
||||
|
||||
@@ -426,14 +462,20 @@ const isFolderLoading = computed(
|
||||
() => isInFolderView.value && folderLoading.value
|
||||
)
|
||||
|
||||
const showLoadingState = computed(
|
||||
() =>
|
||||
(loading.value || isFolderLoading.value) && displayAssets.value.length === 0
|
||||
)
|
||||
const showLoadingState = computed(() => {
|
||||
const isPrimaryLoading =
|
||||
(loading.value || isFolderLoading.value) &&
|
||||
filteredAssets.value.length === 0
|
||||
const isUngroupedResolvingEmpty =
|
||||
!groupByJob.value && isResolving.value && ungroupedAssets.value.length === 0
|
||||
return isPrimaryLoading || isUngroupedResolvingEmpty
|
||||
})
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value && !isFolderLoading.value && displayAssets.value.length === 0
|
||||
!loading.value &&
|
||||
!isFolderLoading.value &&
|
||||
filteredAssets.value.length === 0
|
||||
)
|
||||
|
||||
watch(visibleAssets, (newAssets) => {
|
||||
|
||||
@@ -239,4 +239,32 @@ describe('useCachedRequest', () => {
|
||||
await cachedRequest.call(123)
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not cache aborted requests', async () => {
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
let callCount = 0
|
||||
const abortFn = vi.fn(async (_params: unknown, _signal?: AbortSignal) => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
const error = new DOMException(
|
||||
'The operation was aborted.',
|
||||
'AbortError'
|
||||
)
|
||||
throw error
|
||||
}
|
||||
return { data: 'success' }
|
||||
})
|
||||
|
||||
const cachedRequest = useCachedRequest(abortFn)
|
||||
|
||||
// First call throws AbortError — should NOT be cached
|
||||
const result1 = await cachedRequest.call('key')
|
||||
expect(result1).toBeNull()
|
||||
|
||||
// Second call should retry (not use cached null)
|
||||
const result2 = await cachedRequest.call('key')
|
||||
expect(result2).toEqual({ data: 'success' })
|
||||
expect(abortFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,6 +48,10 @@ export function useCachedRequest<TParams, TResult>(
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
// Don't cache aborted requests — they should be retried
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return null
|
||||
}
|
||||
// Set cache on error to prevent retrying bad requests
|
||||
cache.set(cacheKey, null)
|
||||
return null
|
||||
|
||||
@@ -763,7 +763,10 @@
|
||||
"filterAudio": "Audio",
|
||||
"filter3D": "3D",
|
||||
"filterText": "Text",
|
||||
"viewSettings": "View settings"
|
||||
"viewSettings": "View settings",
|
||||
"groupByJob": "Group assets by job",
|
||||
"showAssetNames": "Show asset names",
|
||||
"showAssetDetails": "Show asset details"
|
||||
},
|
||||
"backToAssets": "Back to all assets",
|
||||
"folderView": {
|
||||
|
||||
@@ -105,10 +105,11 @@
|
||||
>
|
||||
<!-- Left side: Media name and metadata -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Title -->
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- Metadata -->
|
||||
<div class="flex gap-1.5 text-xs text-muted-foreground">
|
||||
<MediaTitle v-if="showAssetNames" :file-name="fileName" />
|
||||
<div
|
||||
v-if="showAssetDetails"
|
||||
class="flex gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<span v-if="formattedDuration">{{ formattedDuration }}</span>
|
||||
<span v-if="metaInfo">{{ metaInfo }}</span>
|
||||
</div>
|
||||
@@ -134,7 +135,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
inject,
|
||||
provide,
|
||||
ref,
|
||||
toRef
|
||||
} from 'vue'
|
||||
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
@@ -153,7 +161,11 @@ import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import {
|
||||
MediaAssetKey,
|
||||
ShowAssetDetailsKey,
|
||||
ShowAssetNamesKey
|
||||
} from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
|
||||
@@ -181,6 +193,9 @@ const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||
outputCount?: number
|
||||
}>()
|
||||
|
||||
const showAssetNames = inject(ShowAssetNamesKey, ref(true))
|
||||
const showAssetDetails = inject(ShowAssetDetailsKey, ref(true))
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
// Get deletion state from store
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 px-3 pt-2 2xl:px-4">
|
||||
<SearchInput
|
||||
:model-value="searchQuery"
|
||||
:placeholder="
|
||||
@@ -27,6 +27,9 @@
|
||||
<MediaAssetSettingsMenu
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:group-by-job="groupByJob"
|
||||
v-model:show-asset-names="showAssetNames"
|
||||
v-model:show-asset-details="showAssetDetails"
|
||||
:show-sort-options="isCloud"
|
||||
:show-generation-time-sort
|
||||
/>
|
||||
@@ -59,6 +62,13 @@ const emit = defineEmits<{
|
||||
|
||||
const sortBy = defineModel<SortBy>('sortBy', { required: true })
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
const groupByJob = defineModel<boolean>('groupByJob', { required: true })
|
||||
const showAssetNames = defineModel<boolean>('showAssetNames', {
|
||||
required: true
|
||||
})
|
||||
const showAssetDetails = defineModel<boolean>('showAssetDetails', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const handleSearchChange = (value: string | undefined) => {
|
||||
emit('update:searchQuery', value ?? '')
|
||||
|
||||
@@ -30,6 +30,49 @@
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div class="my-1 w-full border-b border-border-subtle" />
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
:aria-pressed="groupByJob"
|
||||
@click="groupByJob = !groupByJob"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.groupByJob') }}</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="!groupByJob && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div class="my-1 w-full border-b border-border-subtle" />
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
:aria-pressed="showAssetNames"
|
||||
@click="showAssetNames = !showAssetNames"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.showAssetNames') }}</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="!showAssetNames && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
:aria-pressed="showAssetDetails"
|
||||
@click="showAssetDetails = !showAssetDetails"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.showAssetDetails') }}</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="!showAssetDetails && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<template v-if="showSortOptions">
|
||||
<div class="my-1 w-full border-b border-border-subtle" />
|
||||
|
||||
@@ -99,6 +142,13 @@ const { showSortOptions = false, showGenerationTimeSort = false } =
|
||||
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
const sortBy = defineModel<SortBy>('sortBy', { required: true })
|
||||
const groupByJob = defineModel<boolean>('groupByJob', { required: true })
|
||||
const showAssetNames = defineModel<boolean>('showAssetNames', {
|
||||
required: true
|
||||
})
|
||||
const showAssetDetails = defineModel<boolean>('showAssetDetails', {
|
||||
required: true
|
||||
})
|
||||
|
||||
function handleViewModeChange(value: 'list' | 'grid') {
|
||||
viewMode.value = value
|
||||
|
||||
212
src/platform/assets/composables/useUngroupedAssets.test.ts
Normal file
212
src/platform/assets/composables/useUngroupedAssets.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type * as OutputAssetUtil from '@/platform/assets/utils/outputAssetUtil'
|
||||
|
||||
import { useUngroupedAssets } from './useUngroupedAssets'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveOutputAssetItems: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof OutputAssetUtil>()
|
||||
return {
|
||||
...actual,
|
||||
resolveOutputAssetItems: mocks.resolveOutputAssetItems
|
||||
}
|
||||
})
|
||||
|
||||
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: 'image.png',
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
user_metadata: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMultiOutputAsset(
|
||||
jobId: string,
|
||||
outputCount: number,
|
||||
overrides: Partial<AssetItem> = {}
|
||||
): AssetItem {
|
||||
return createAsset({
|
||||
id: `asset-${jobId}`,
|
||||
name: `${jobId}.png`,
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId: 'node-1',
|
||||
subfolder: 'outputs',
|
||||
outputCount
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('useUngroupedAssets', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('returns assets as-is when groupByJob is true', async () => {
|
||||
const asset = createAsset()
|
||||
const assets = ref([asset])
|
||||
const groupByJob = ref(true)
|
||||
|
||||
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
|
||||
await flushPromises()
|
||||
|
||||
expect(ungroupedAssets.value).toEqual([asset])
|
||||
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns single-output assets as-is when ungrouped', async () => {
|
||||
const singleOutput = createAsset({ id: 'single' })
|
||||
const singleCountAsset = createMultiOutputAsset('job-1', 1)
|
||||
const assets = ref([singleOutput, singleCountAsset])
|
||||
const groupByJob = ref(false)
|
||||
|
||||
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
|
||||
await flushPromises()
|
||||
|
||||
expect(ungroupedAssets.value).toEqual([singleOutput, singleCountAsset])
|
||||
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves multi-output assets into individual children', async () => {
|
||||
const parent = createMultiOutputAsset('job-1', 3)
|
||||
const childA = createAsset({ id: 'child-a', name: 'child-a.png' })
|
||||
const childB = createAsset({ id: 'child-b', name: 'child-b.png' })
|
||||
const childC = createAsset({ id: 'child-c', name: 'child-c.png' })
|
||||
|
||||
mocks.resolveOutputAssetItems.mockResolvedValue([childA, childB, childC])
|
||||
|
||||
const assets = ref([parent])
|
||||
const groupByJob = ref(false)
|
||||
|
||||
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
|
||||
await flushPromises()
|
||||
|
||||
expect(ungroupedAssets.value).toEqual([childA, childB, childC])
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job-1' }),
|
||||
{ createdAt: parent.created_at, signal: expect.any(AbortSignal) }
|
||||
)
|
||||
})
|
||||
|
||||
it('mixes single and multi-output assets correctly', async () => {
|
||||
const single = createAsset({ id: 'single', name: 'single.png' })
|
||||
const parent = createMultiOutputAsset('job-2', 2)
|
||||
const childA = createAsset({ id: 'child-a' })
|
||||
const childB = createAsset({ id: 'child-b' })
|
||||
|
||||
mocks.resolveOutputAssetItems.mockResolvedValue([childA, childB])
|
||||
|
||||
const assets = ref([single, parent])
|
||||
const groupByJob = ref(false)
|
||||
|
||||
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
|
||||
await flushPromises()
|
||||
|
||||
expect(ungroupedAssets.value).toEqual([single, childA, childB])
|
||||
})
|
||||
|
||||
it('falls back to original asset when resolution returns null', async () => {
|
||||
const parent = createMultiOutputAsset('job-1', 3)
|
||||
mocks.resolveOutputAssetItems.mockResolvedValue(null)
|
||||
|
||||
const assets = ref([parent])
|
||||
const groupByJob = ref(false)
|
||||
|
||||
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
|
||||
await flushPromises()
|
||||
|
||||
expect(ungroupedAssets.value).toEqual([parent])
|
||||
})
|
||||
|
||||
it('falls back to original asset when resolution returns empty array', async () => {
|
||||
const parent = createMultiOutputAsset('job-empty', 3)
|
||||
mocks.resolveOutputAssetItems.mockResolvedValue([])
|
||||
|
||||
const assets = ref([parent])
|
||||
const groupByJob = ref(false)
|
||||
|
||||
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
|
||||
await flushPromises()
|
||||
|
||||
expect(ungroupedAssets.value).toEqual([parent])
|
||||
})
|
||||
|
||||
it('triggers resolution when groupByJob toggles from true to false', async () => {
|
||||
const parent = createMultiOutputAsset('job-1', 2)
|
||||
const childA = createAsset({ id: 'child-a' })
|
||||
const childB = createAsset({ id: 'child-b' })
|
||||
mocks.resolveOutputAssetItems.mockResolvedValue([childA, childB])
|
||||
|
||||
const assets = ref([parent])
|
||||
const groupByJob = ref(true)
|
||||
|
||||
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
|
||||
await flushPromises()
|
||||
|
||||
expect(ungroupedAssets.value).toEqual([parent])
|
||||
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
|
||||
groupByJob.value = false
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
expect(ungroupedAssets.value).toEqual([childA, childB])
|
||||
})
|
||||
|
||||
it('does not re-resolve cached jobIds on repeated toggles', async () => {
|
||||
const parent = createMultiOutputAsset('job-1', 2)
|
||||
const children = [
|
||||
createAsset({ id: 'child-a' }),
|
||||
createAsset({ id: 'child-b' })
|
||||
]
|
||||
mocks.resolveOutputAssetItems.mockResolvedValue(children)
|
||||
|
||||
const assets = ref([parent])
|
||||
const groupByJob = ref(false)
|
||||
|
||||
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
expect(ungroupedAssets.value).toEqual(children)
|
||||
|
||||
// Toggle back and forth
|
||||
groupByJob.value = true
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
groupByJob.value = false
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
// useCachedRequest should return cached result
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
expect(ungroupedAssets.value).toEqual(children)
|
||||
})
|
||||
|
||||
it('falls back to original asset when resolution rejects', async () => {
|
||||
const parent = createMultiOutputAsset('job-err', 3)
|
||||
mocks.resolveOutputAssetItems.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const assets = ref([parent])
|
||||
const groupByJob = ref(false)
|
||||
|
||||
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
|
||||
await flushPromises()
|
||||
|
||||
// useCachedRequest converts errors to null, fallback to [asset]
|
||||
expect(ungroupedAssets.value).toEqual([parent])
|
||||
})
|
||||
})
|
||||
71
src/platform/assets/composables/useUngroupedAssets.ts
Normal file
71
src/platform/assets/composables/useUngroupedAssets.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useCachedRequest } from '@/composables/useCachedRequest'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
|
||||
export function useUngroupedAssets(
|
||||
assets: Ref<AssetItem[]>,
|
||||
groupByJob: Ref<boolean>
|
||||
) {
|
||||
const { call: cachedResolve } = useCachedRequest(
|
||||
(jobId: string, signal?: AbortSignal) => {
|
||||
const asset = assets.value.find((a) => {
|
||||
const m = getOutputAssetMetadata(a.user_metadata)
|
||||
return m?.jobId === jobId
|
||||
})
|
||||
if (!asset) return Promise.resolve(null)
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)!
|
||||
return resolveOutputAssetItems(metadata, {
|
||||
createdAt: asset.created_at,
|
||||
signal
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const isResolving = ref(false)
|
||||
|
||||
const resolvedAssets = computedAsync(
|
||||
async () => {
|
||||
if (groupByJob.value) return []
|
||||
|
||||
const entries = assets.value.map((asset) => ({
|
||||
asset,
|
||||
metadata: getOutputAssetMetadata(asset.user_metadata)
|
||||
}))
|
||||
|
||||
for (const { metadata } of entries) {
|
||||
if ((metadata?.outputCount ?? 1) > 1 && metadata?.jobId) {
|
||||
void cachedResolve(metadata.jobId).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const result: AssetItem[] = []
|
||||
for (const { asset, metadata } of entries) {
|
||||
const count = metadata?.outputCount ?? 1
|
||||
if (count <= 1 || !metadata?.jobId) {
|
||||
result.push(asset)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const children = await cachedResolve(metadata.jobId)
|
||||
result.push(...(children?.length ? children : [asset]))
|
||||
} catch {
|
||||
result.push(asset)
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
[],
|
||||
isResolving
|
||||
)
|
||||
|
||||
const ungroupedAssets = computed(() =>
|
||||
groupByJob.value ? assets.value : resolvedAssets.value
|
||||
)
|
||||
|
||||
return { ungroupedAssets, isResolving }
|
||||
}
|
||||
@@ -49,3 +49,9 @@ interface MediaAssetProviderValue {
|
||||
|
||||
export const MediaAssetKey: InjectionKey<MediaAssetProviderValue> =
|
||||
Symbol('mediaAsset')
|
||||
|
||||
export const ShowAssetNamesKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('showAssetNames')
|
||||
|
||||
export const ShowAssetDetailsKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('showAssetDetails')
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata)
|
||||
|
||||
expect(mocks.getJobDetail).toHaveBeenCalledWith('job-2')
|
||||
expect(mocks.getJobDetail).toHaveBeenCalledWith('job-2', undefined)
|
||||
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
|
||||
jobDetail
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ type OutputAssetMapOptions = {
|
||||
type ResolveOutputAssetItemsOptions = {
|
||||
createdAt?: string
|
||||
excludeOutputKey?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
type OutputKeyParts = {
|
||||
@@ -90,11 +91,11 @@ function mapOutputsToAssetItems({
|
||||
|
||||
export async function resolveOutputAssetItems(
|
||||
metadata: OutputAssetMetadata,
|
||||
{ createdAt, excludeOutputKey }: ResolveOutputAssetItemsOptions = {}
|
||||
{ createdAt, excludeOutputKey, signal }: ResolveOutputAssetItemsOptions = {}
|
||||
): Promise<AssetItem[]> {
|
||||
let outputsToDisplay = metadata.allOutputs ?? []
|
||||
if (shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) {
|
||||
const jobDetail = await getJobDetail(metadata.jobId)
|
||||
const jobDetail = await getJobDetail(metadata.jobId, signal)
|
||||
const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail)
|
||||
if (previewableOutputs.length) {
|
||||
outputsToDisplay = previewableOutputs
|
||||
|
||||
@@ -227,7 +227,9 @@ describe('fetchJobs', () => {
|
||||
|
||||
const result = await fetchJobDetail(mockFetch, 'job1')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/jobs/job1')
|
||||
expect(mockFetch).toHaveBeenCalledWith('/jobs/job1', {
|
||||
signal: undefined
|
||||
})
|
||||
expect(result?.id).toBe('job1')
|
||||
expect(result?.outputs).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -118,11 +118,12 @@ export async function fetchQueue(
|
||||
* Fetches full job details from /jobs/{job_id}
|
||||
*/
|
||||
export async function fetchJobDetail(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
jobId: JobId
|
||||
fetchApi: (url: string, options?: RequestInit) => Promise<Response>,
|
||||
jobId: JobId,
|
||||
signal?: AbortSignal
|
||||
): Promise<JobDetail | undefined> {
|
||||
try {
|
||||
const res = await fetchApi(`/jobs/${encodeURIComponent(jobId)}`)
|
||||
const res = await fetchApi(`/jobs/${encodeURIComponent(jobId)}`, { signal })
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`Job not found for job ${jobId}`)
|
||||
|
||||
@@ -51,7 +51,9 @@ describe('fetchJobDetail', () => {
|
||||
|
||||
await fetchJobDetail(mockFetchApi, 'test-job-id')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/jobs/test-job-id')
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/jobs/test-job-id', {
|
||||
signal: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('should return job detail with workflow and outputs', async () => {
|
||||
|
||||
@@ -1008,8 +1008,11 @@ export class ComfyApi extends EventTarget {
|
||||
* @param jobId The job ID
|
||||
* @returns Full job details or undefined if not found
|
||||
*/
|
||||
async getJobDetail(jobId: string): Promise<JobDetail | undefined> {
|
||||
return fetchJobDetail(this.fetchApi.bind(this), jobId)
|
||||
async getJobDetail(
|
||||
jobId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<JobDetail | undefined> {
|
||||
return fetchJobDetail(this.fetchApi.bind(this), jobId, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -323,7 +323,7 @@ describe('jobOutputCache', () => {
|
||||
const result = await getJobDetail(jobId)
|
||||
|
||||
expect(result).toEqual(mockDetail)
|
||||
expect(api.getJobDetail).toHaveBeenCalledWith(jobId)
|
||||
expect(api.getJobDetail).toHaveBeenCalledWith(jobId, undefined)
|
||||
})
|
||||
|
||||
it('returns cached job detail on subsequent calls', async () => {
|
||||
|
||||
@@ -149,13 +149,14 @@ export function getPreviewableOutputsFromJobDetail(
|
||||
// ===== Job Detail Caching =====
|
||||
|
||||
export async function getJobDetail(
|
||||
jobId: string
|
||||
jobId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<JobDetail | undefined> {
|
||||
const cached = jobDetailCache.get(jobId)
|
||||
if (cached) return cached
|
||||
|
||||
try {
|
||||
const detail = await api.getJobDetail(jobId)
|
||||
const detail = await api.getJobDetail(jobId, signal)
|
||||
if (detail) {
|
||||
jobDetailCache.set(jobId, detail)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user