mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 14:16:00 +00:00
[backport cloud/1.44] fix: stabilize multi-output expansion + simplify cloud output fetch (FE-227) (#12006) (#12353)
Backport of #12006 to cloud/1.44. ## Conflict resolution One conflict in `src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue` (imports + dropdown source). On main, the original PR replaced `useMediaAssets('output')` with `isCloud ? useFlatOutputAssets() : useAssetsApi('output')`. On `cloud/1.44`, the local path still goes through `useMediaAssets`, which itself internally gates `isCloud → useAssetsApi : useInternalFilesApi`. Resolution preserves the new cloud branch (which is the whole point of this PR — single `getAssetsByTag('output')` instead of jobs-walk + per-job expansion) while keeping `useMediaAssets('output')` for the local path on this branch: ```ts const outputMediaAssets = isCloud ? useFlatOutputAssets() : useMediaAssets('output') ``` All other files auto-merged. ## Verification - `pnpm typecheck` ✅ - `pnpm test:unit` — `assetsStore.test.ts` (60) + `useWidgetSelectItems.test.ts` (40) = 100/100 ✅ ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12353-backport-cloud-1-44-fix-stabilize-multi-output-expansion-simplify-cloud-output-fetc-3666d73d3650815280a4f9207332e058) by [Unito](https://www.unito.io)
This commit is contained in:
27
src/platform/assets/composables/media/useFlatOutputAssets.ts
Normal file
27
src/platform/assets/composables/media/useFlatOutputAssets.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
import type { IAssetsProvider } from './IAssetsProvider'
|
||||
|
||||
export function useFlatOutputAssets(): IAssetsProvider {
|
||||
const store = useAssetsStore()
|
||||
const {
|
||||
flatOutputAssets,
|
||||
flatOutputLoading,
|
||||
flatOutputError,
|
||||
flatOutputHasMore,
|
||||
flatOutputIsLoadingMore
|
||||
} = storeToRefs(store)
|
||||
|
||||
return {
|
||||
media: flatOutputAssets,
|
||||
loading: flatOutputLoading,
|
||||
error: flatOutputError,
|
||||
fetchMediaList: store.updateFlatOutputs,
|
||||
refresh: store.updateFlatOutputs,
|
||||
loadMore: store.loadMoreFlatOutputs,
|
||||
hasMore: flatOutputHasMore,
|
||||
isLoadingMore: flatOutputIsLoadingMore
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,8 @@ const DEFAULT_LIMIT = 500
|
||||
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
|
||||
|
||||
export const MODELS_TAG = 'models'
|
||||
export const INPUT_TAG = 'input'
|
||||
export const OUTPUT_TAG = 'output'
|
||||
/** Asset tag used by the backend for placeholder records that are not installed. */
|
||||
export const MISSING_TAG = 'missing'
|
||||
const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG]
|
||||
|
||||
@@ -204,3 +204,13 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
if (curatedName && curatedName !== asset.name) return curatedName
|
||||
return getAssetDisplayFilename(asset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename component the cloud `/api/view` endpoint resolves
|
||||
* for this asset — `asset_hash` when present (cloud assets are hash-keyed
|
||||
* in storage), otherwise `asset.name`. Use this when constructing widget
|
||||
* values or media URLs that must round-trip through the view endpoint.
|
||||
*/
|
||||
export function getAssetUrlFilename(asset: AssetItem): string {
|
||||
return asset.asset_hash || asset.name
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useFlatOutputAssets } from '@/platform/assets/composables/media/useFlatOutputAssets'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
||||
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
@@ -47,7 +49,9 @@ const modelValue = defineModel<string | undefined>({
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const outputMediaAssets = useMediaAssets('output')
|
||||
const outputMediaAssets = isCloud
|
||||
? useFlatOutputAssets()
|
||||
: useMediaAssets('output')
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
|
||||
@@ -681,6 +681,201 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(dropdownItems.value[0].name).toBe('preview.png [output]')
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not expand a hash-keyed asset even if its metadata reports outputCount > 1', async () => {
|
||||
// Defense against future cloud-schema changes: if a flat output row
|
||||
// ever ships with both asset_hash AND multi-output user_metadata, the
|
||||
// watcher must NOT replace it with synthesized AssetItems lacking the
|
||||
// hash, or select+load reverts to the FE-227 broken state.
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-flat-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-future',
|
||||
nodeId: '9',
|
||||
subfolder: '',
|
||||
outputCount: 4,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: 'should-not-replace.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '9',
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
expect(dropdownItems.value[0].name).toBe(
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png [output]'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses asset_hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-out-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
preview_url: '/api/view?filename=039b...0b13.png',
|
||||
tags: ['output']
|
||||
}
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
// The value (item.name) — what becomes modelValue on click — must be the
|
||||
// hash-keyed path so /api/view resolves it. Cloud's hash is in
|
||||
// asset_hash, not asset.name (which is the human filename).
|
||||
expect(dropdownItems.value[0].name).toBe(
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png [output]'
|
||||
)
|
||||
// The label keeps the human filename for the dropdown UI.
|
||||
expect(dropdownItems.value[0].label).toContain('z-image-turbo_00093_.png')
|
||||
})
|
||||
|
||||
it('falls back to asset.name when asset_hash is absent (local/history path)', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'local-1',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
tags: ['output']
|
||||
}
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
expect(dropdownItems.value[0].name).toBe('ComfyUI_00001_.png [output]')
|
||||
})
|
||||
|
||||
it('does not partially expand the list while some multi-output jobs are still resolving (FE-227)', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-FIRST', 'previewFirst.png', '1', 3),
|
||||
makeMultiOutputAsset('job-SECOND', 'previewSecond.png', '2', 2)
|
||||
]
|
||||
|
||||
let resolveFirst!: (items: AssetItem[]) => void
|
||||
let resolveSecond!: (items: AssetItem[]) => void
|
||||
const firstPromise = new Promise<AssetItem[]>((res) => {
|
||||
resolveFirst = res
|
||||
})
|
||||
const secondPromise = new Promise<AssetItem[]>((res) => {
|
||||
resolveSecond = res
|
||||
})
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(
|
||||
async (meta: { jobId: string }) => {
|
||||
if (meta.jobId === 'job-FIRST') return firstPromise
|
||||
if (meta.jobId === 'job-SECOND') return secondPromise
|
||||
return []
|
||||
}
|
||||
)
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value.map((i) => i.name)).toEqual([
|
||||
'previewFirst.png [output]',
|
||||
'previewSecond.png [output]'
|
||||
])
|
||||
|
||||
resolveSecond([
|
||||
{
|
||||
id: 'job-SECOND-2--out2a.png',
|
||||
name: 'out2a.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-SECOND-2--out2b.png',
|
||||
name: 'out2b.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value.map((i) => i.name)).toEqual([
|
||||
'previewFirst.png [output]',
|
||||
'previewSecond.png [output]'
|
||||
])
|
||||
|
||||
resolveFirst([
|
||||
{
|
||||
id: 'job-FIRST-1--out1a.png',
|
||||
name: 'out1a.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-FIRST-1--out1b.png',
|
||||
name: 'out1b.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-FIRST-1--out1c.png',
|
||||
name: 'out1c.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(dropdownItems.value).toHaveLength(5)
|
||||
})
|
||||
|
||||
expect(dropdownItems.value.map((i) => i.name)).toEqual([
|
||||
'out1a.png [output]',
|
||||
'out1b.png [output]',
|
||||
'out1c.png [output]',
|
||||
'out2a.png [output]',
|
||||
'out2b.png [output]'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('output asset subfolder', () => {
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
getAssetBaseModels,
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
getAssetFilename,
|
||||
getAssetUrlFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -110,7 +111,6 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
})
|
||||
|
||||
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
|
||||
const pendingJobIds = new Set<string>()
|
||||
|
||||
watch(
|
||||
() => outputMediaAssets.media.value,
|
||||
@@ -118,10 +118,22 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
pendingJobIds.clear()
|
||||
})
|
||||
|
||||
const seenJobIds = new Set<string>()
|
||||
const jobsToResolve: Array<{
|
||||
jobId: string
|
||||
meta: ReturnType<typeof getOutputAssetMetadata>
|
||||
createdAt?: string
|
||||
}> = []
|
||||
|
||||
for (const asset of assets) {
|
||||
// Hash-keyed assets are leaf rows from the cloud `/assets` API and
|
||||
// already carry their own URL-resolvable filename. Expanding them via
|
||||
// resolveOutputAssetItems would synthesize sibling AssetItems without
|
||||
// an asset_hash and reintroduce the FE-227 hash→name fallback bug.
|
||||
if (asset.asset_hash) continue
|
||||
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
@@ -129,29 +141,41 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
if (
|
||||
outputCount <= 1 ||
|
||||
resolvedByJobId.value.has(meta.jobId) ||
|
||||
pendingJobIds.has(meta.jobId)
|
||||
seenJobIds.has(meta.jobId)
|
||||
)
|
||||
continue
|
||||
|
||||
pendingJobIds.add(meta.jobId)
|
||||
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
|
||||
.then((resolved) => {
|
||||
if (cancelled || !resolved.length) return
|
||||
const next = new Map(resolvedByJobId.value)
|
||||
next.set(meta.jobId, resolved)
|
||||
resolvedByJobId.value = next
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'Failed to resolve multi-output job',
|
||||
meta.jobId,
|
||||
error
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
pendingJobIds.delete(meta.jobId)
|
||||
})
|
||||
seenJobIds.add(meta.jobId)
|
||||
jobsToResolve.push({
|
||||
jobId: meta.jobId,
|
||||
meta,
|
||||
createdAt: asset.created_at
|
||||
})
|
||||
}
|
||||
|
||||
if (jobsToResolve.length === 0) return
|
||||
|
||||
void Promise.all(
|
||||
jobsToResolve.map(({ jobId, meta, createdAt }) =>
|
||||
resolveOutputAssetItems(meta!, { createdAt })
|
||||
.then((resolved) => ({ jobId, resolved }))
|
||||
.catch((error) => {
|
||||
console.warn('Failed to resolve multi-output job', jobId, error)
|
||||
return { jobId, resolved: [] as AssetItem[] }
|
||||
})
|
||||
)
|
||||
).then((results) => {
|
||||
if (cancelled) return
|
||||
|
||||
const next = new Map(resolvedByJobId.value)
|
||||
let changed = false
|
||||
for (const { jobId, resolved } of results) {
|
||||
if (!resolved.length) continue
|
||||
next.set(jobId, resolved)
|
||||
changed = true
|
||||
}
|
||||
if (changed) resolvedByJobId.value = next
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -193,13 +217,14 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
|
||||
if (seen.has(asset.id)) continue
|
||||
seen.add(asset.id)
|
||||
const filenameForUrl = getAssetUrlFilename(asset)
|
||||
const subfolder =
|
||||
kind === 'mesh'
|
||||
? getOutputAssetMetadata(asset.user_metadata)?.subfolder
|
||||
: undefined
|
||||
const pathWithSubfolder = subfolder
|
||||
? `${subfolder}/${asset.name}`
|
||||
: asset.name
|
||||
? `${subfolder}/${filenameForUrl}`
|
||||
: filenameForUrl
|
||||
const annotatedPath = `${pathWithSubfolder} [output]`
|
||||
if (missing.has(annotatedPath)) continue
|
||||
const displayLabel = `${getAssetDisplayFilename(asset)} [output]`
|
||||
@@ -208,7 +233,7 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
preview_url:
|
||||
kind === 'mesh'
|
||||
? ''
|
||||
: asset.preview_url || getMediaUrl(asset.name, 'output', kind),
|
||||
: asset.preview_url || getMediaUrl(filenameForUrl, 'output', kind),
|
||||
name: annotatedPath,
|
||||
label: getDisplayLabel(displayLabel, labelFn)
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { nextTick, watch } from 'vue'
|
||||
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
@@ -30,7 +31,9 @@ vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
updateAsset: vi.fn(),
|
||||
addAssetTags: vi.fn(),
|
||||
removeAssetTags: vi.fn()
|
||||
}
|
||||
},
|
||||
INPUT_TAG: 'input',
|
||||
OUTPUT_TAG: 'output'
|
||||
}))
|
||||
|
||||
// Mock distribution type - hoisted so it can be changed per test
|
||||
@@ -1420,3 +1423,137 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
const FLAT_OUTPUT_PAGE_SIZE = 200
|
||||
|
||||
const makeAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
asset_hash?: string
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
asset_hash,
|
||||
size: 0,
|
||||
tags: ['output']
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetches outputs via getAssetsByTag with the output tag and page size', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
makeAsset('a1', 'image1.png', 'hash1.png'),
|
||||
makeAsset('a2', 'image2.png', 'hash2.png')
|
||||
])
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
|
||||
'output',
|
||||
true,
|
||||
expect.objectContaining({ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 })
|
||||
)
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1', 'a2'])
|
||||
})
|
||||
|
||||
it('marks hasMore=false when the page is short', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
makeAsset('a1', 'one.png')
|
||||
])
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('marks hasMore=true when a full page is returned', async () => {
|
||||
const fullPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce(fullPage)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('appends and dedupes on loadMoreFlatOutputs', async () => {
|
||||
const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
const secondPage = [
|
||||
makeAsset('a0', 'duplicate.png'),
|
||||
makeAsset('newId', 'new.png')
|
||||
]
|
||||
vi.mocked(assetService.getAssetsByTag)
|
||||
.mockResolvedValueOnce(firstPage)
|
||||
.mockResolvedValueOnce(secondPage)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(store.flatOutputAssets).toHaveLength(FLAT_OUTPUT_PAGE_SIZE + 1)
|
||||
expect(store.flatOutputAssets.at(-1)?.id).toBe('newId')
|
||||
})
|
||||
|
||||
it('records error and clears media on initial-fetch failure', async () => {
|
||||
const err = new Error('network down')
|
||||
vi.mocked(assetService.getAssetsByTag).mockRejectedValueOnce(err)
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const store = useAssetsStore()
|
||||
const result = await store.updateFlatOutputs()
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(store.flatOutputError).toBe(err)
|
||||
expect(store.flatOutputLoading).toBe(false)
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('refresh resets pagination', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag)
|
||||
.mockResolvedValueOnce(
|
||||
Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce([makeAsset('fresh', 'fresh.png')])
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh'])
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('dedupes concurrent fetches into a single request', async () => {
|
||||
let resolvePage!: (assets: AssetItem[]) => void
|
||||
const pagePromise = new Promise<AssetItem[]>((res) => {
|
||||
resolvePage = res
|
||||
})
|
||||
vi.mocked(assetService.getAssetsByTag).mockReturnValueOnce(pagePromise)
|
||||
|
||||
const store = useAssetsStore()
|
||||
const p1 = store.updateFlatOutputs()
|
||||
const p2 = store.updateFlatOutputs()
|
||||
|
||||
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolvePage([makeAsset('shared-1', 'shared.png', 'h.png')])
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,11 @@ import type {
|
||||
AssetItem,
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
INPUT_TAG,
|
||||
OUTPUT_TAG,
|
||||
assetService
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import type { PaginationOptions } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
@@ -46,7 +50,7 @@ async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
|
||||
* Fetch input files from cloud service
|
||||
*/
|
||||
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
||||
return await assetService.getAssetsByTag('input', false, {
|
||||
return await assetService.getAssetsByTag(INPUT_TAG, false, {
|
||||
limit: INPUT_LIMIT
|
||||
})
|
||||
}
|
||||
@@ -89,6 +93,7 @@ function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
|
||||
|
||||
const BATCH_SIZE = 200
|
||||
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
|
||||
const FLAT_OUTPUT_PAGE_SIZE = 200
|
||||
|
||||
export const useAssetsStore = defineStore('assets', () => {
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
@@ -255,6 +260,65 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const flatOutputAssets = ref<AssetItem[]>([])
|
||||
const flatOutputLoading = ref(false)
|
||||
const flatOutputError = ref<unknown>(null)
|
||||
const flatOutputOffset = ref(0)
|
||||
const flatOutputHasMore = ref(true)
|
||||
const flatOutputIsLoadingMore = ref(false)
|
||||
const flatOutputSeenIds = new Set<string>()
|
||||
let flatOutputInFlight: Promise<AssetItem[]> | null = null
|
||||
|
||||
async function fetchFlatOutputs(loadMore: boolean): Promise<AssetItem[]> {
|
||||
if (flatOutputInFlight) return flatOutputInFlight
|
||||
|
||||
if (loadMore) {
|
||||
if (!flatOutputHasMore.value) return flatOutputAssets.value
|
||||
flatOutputIsLoadingMore.value = true
|
||||
} else {
|
||||
flatOutputLoading.value = true
|
||||
flatOutputOffset.value = 0
|
||||
flatOutputHasMore.value = true
|
||||
flatOutputSeenIds.clear()
|
||||
}
|
||||
flatOutputError.value = null
|
||||
|
||||
flatOutputInFlight = (async () => {
|
||||
try {
|
||||
const page = await assetService.getAssetsByTag(OUTPUT_TAG, true, {
|
||||
limit: FLAT_OUTPUT_PAGE_SIZE,
|
||||
offset: flatOutputOffset.value
|
||||
})
|
||||
const fresh = loadMore
|
||||
? page.filter((asset) => !flatOutputSeenIds.has(asset.id))
|
||||
: page
|
||||
for (const asset of fresh) flatOutputSeenIds.add(asset.id)
|
||||
flatOutputAssets.value = loadMore
|
||||
? [...flatOutputAssets.value, ...fresh]
|
||||
: page
|
||||
flatOutputOffset.value += page.length
|
||||
flatOutputHasMore.value = page.length === FLAT_OUTPUT_PAGE_SIZE
|
||||
return flatOutputAssets.value
|
||||
} catch (err) {
|
||||
flatOutputError.value = err
|
||||
console.error('Failed to fetch output assets:', err)
|
||||
return loadMore ? flatOutputAssets.value : []
|
||||
} finally {
|
||||
if (loadMore) flatOutputIsLoadingMore.value = false
|
||||
else flatOutputLoading.value = false
|
||||
flatOutputInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return flatOutputInFlight
|
||||
}
|
||||
|
||||
const updateFlatOutputs = () => fetchFlatOutputs(false)
|
||||
const loadMoreFlatOutputs = async () => {
|
||||
if (flatOutputIsLoadingMore.value) return
|
||||
await fetchFlatOutputs(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of asset hash filename to asset item for O(1) lookup
|
||||
* Cloud assets use asset_hash for the hash-based filename
|
||||
@@ -783,6 +847,15 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
updateHistory,
|
||||
loadMoreHistory,
|
||||
|
||||
// Flat output assets (cloud-only, tag-based)
|
||||
flatOutputAssets,
|
||||
flatOutputLoading,
|
||||
flatOutputError,
|
||||
flatOutputHasMore,
|
||||
flatOutputIsLoadingMore,
|
||||
updateFlatOutputs,
|
||||
loadMoreFlatOutputs,
|
||||
|
||||
// Input mapping helpers
|
||||
inputAssetsByFilename,
|
||||
getInputName,
|
||||
|
||||
Reference in New Issue
Block a user