diff --git a/src/platform/assets/composables/media/useFlatOutputAssets.ts b/src/platform/assets/composables/media/useFlatOutputAssets.ts new file mode 100644 index 0000000000..dcf4986ff0 --- /dev/null +++ b/src/platform/assets/composables/media/useFlatOutputAssets.ts @@ -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 + } +} diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 3ac5dc2c71..4c53574777 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -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] diff --git a/src/platform/assets/utils/assetMetadataUtils.ts b/src/platform/assets/utils/assetMetadataUtils.ts index eabde69429..8dfcde1145 100644 --- a/src/platform/assets/utils/assetMetadataUtils.ts +++ b/src/platform/assets/utils/assetMetadataUtils.ts @@ -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 +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index 970124615e..bef7adc20f 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -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({ const { t } = useI18n() -const outputMediaAssets = useMediaAssets('output') +const outputMediaAssets = isCloud + ? useFlatOutputAssets() + : useMediaAssets('output') const transformCompatProps = useTransformCompatOverlayProps() diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts index 5b6bff0145..6ed62790e4 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts @@ -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((res) => { + resolveFirst = res + }) + const secondPromise = new Promise((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', () => { diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts index 8450bb0f46..60b1460ca4 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts @@ -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()) - const pendingJobIds = new Set() 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() + const jobsToResolve: Array<{ + jobId: string + meta: ReturnType + 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) }) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index bb37cdde6b..ee7cc65fe8 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -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((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']) + }) +}) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index d12955cf15..bd0d755495 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -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 { * Fetch input files from cloud service */ async function fetchInputFilesFromCloud(): Promise { - 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([]) + const flatOutputLoading = ref(false) + const flatOutputError = ref(null) + const flatOutputOffset = ref(0) + const flatOutputHasMore = ref(true) + const flatOutputIsLoadingMore = ref(false) + const flatOutputSeenIds = new Set() + let flatOutputInFlight: Promise | null = null + + async function fetchFlatOutputs(loadMore: boolean): Promise { + 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,