[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:
Dante
2026-05-20 12:01:15 +09:00
committed by GitHub
parent f8a3f462b7
commit e6a751f42f
8 changed files with 502 additions and 29 deletions

View 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
}
}

View File

@@ -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]

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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', () => {

View File

@@ -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)
})

View File

@@ -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'])
})
})

View File

@@ -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,