mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
4 Commits
FE-407-3d-
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7149aefaa9 | ||
|
|
4afe0baa72 | ||
|
|
f508a060a0 | ||
|
|
5f43461623 |
@@ -1,9 +1,62 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { STABLE_INPUT_IMAGE } from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
loadWorkflowAndOpenErrorsTab,
|
||||
openErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
const test = comfyPageFixture
|
||||
const PUBLIC_INPUT_ASSET_HASH = 'nonexistent_test_image_12345.png'
|
||||
const PUBLIC_INPUT_ASSET: Asset = {
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'test-public-input-001',
|
||||
name: 'public_reference_photo.png',
|
||||
asset_hash: PUBLIC_INPUT_ASSET_HASH
|
||||
}
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
const cloudTest = comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
stubCloudAssets: void
|
||||
}>({
|
||||
cloudAssetRequests: async ({ page: _page }, use) => {
|
||||
await use([])
|
||||
},
|
||||
stubCloudAssets: [
|
||||
async ({ cloudAssetRequests, page }, use) => {
|
||||
const assetsRoutePattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetsRouteHandler = (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const includeTags = url.searchParams.get('include_tags') ?? ''
|
||||
const tags = includeTags.split(',').filter(Boolean)
|
||||
const includePublic = url.searchParams.get('include_public') === 'true'
|
||||
const assets =
|
||||
tags.includes('input') && includePublic ? [PUBLIC_INPUT_ASSET] : []
|
||||
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(assets))
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(assetsRoutePattern, assetsRouteHandler)
|
||||
await use()
|
||||
await page.unroute(assetsRoutePattern, assetsRouteHandler)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
@@ -203,3 +256,44 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
cloudTest.describe(
|
||||
'Errors tab - Missing media cloud assets',
|
||||
{
|
||||
tag: '@cloud'
|
||||
},
|
||||
() => {
|
||||
cloudTest.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
cloudTest(
|
||||
'does not report public input assets as missing',
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((requestUrl) => {
|
||||
const url = new URL(requestUrl)
|
||||
const includeTags = url.searchParams.get('include_tags') ?? ''
|
||||
return (
|
||||
includeTags.split(',').includes('input') &&
|
||||
url.searchParams.get('include_public') === 'true'
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await openErrorsTab(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"@primevue/themes": "catalog:",
|
||||
"@sentry/vue": "catalog:",
|
||||
"@sparkjsdev/spark": "catalog:",
|
||||
"@tanstack/vue-query": "catalog:",
|
||||
"@tanstack/vue-virtual": "catalog:",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-link": "catalog:",
|
||||
|
||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@@ -108,6 +108,9 @@ catalogs:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
'@tanstack/vue-query':
|
||||
specifier: ^5.100.9
|
||||
version: 5.100.9
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: ^3.13.12
|
||||
version: 3.13.12
|
||||
@@ -476,6 +479,9 @@ importers:
|
||||
'@sparkjsdev/spark':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.10
|
||||
'@tanstack/vue-query':
|
||||
specifier: 'catalog:'
|
||||
version: 5.100.9(vue@3.5.13(typescript@5.9.3))
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: 'catalog:'
|
||||
version: 3.13.12(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -4197,9 +4203,25 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^8.0.0
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/query-core@5.100.9':
|
||||
resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12':
|
||||
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
|
||||
|
||||
'@tanstack/vue-query@5.100.9':
|
||||
resolution: {integrity: sha512-wGiv/AirRuITlTDl87zdBRaZIZTejMItUswKgMzzcX/1gfn95iKw2EaCuz7qlX9ceB0DwBj9FqaroLnDoJCecg==}
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.1.2
|
||||
vue: ^2.6.0 || ^3.3.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12':
|
||||
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
|
||||
peerDependencies:
|
||||
@@ -8679,6 +8701,9 @@ packages:
|
||||
remark-stringify@11.0.0:
|
||||
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
||||
|
||||
remove-accents@0.5.0:
|
||||
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
|
||||
|
||||
request-light@0.5.8:
|
||||
resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==}
|
||||
|
||||
@@ -9883,8 +9908,8 @@ packages:
|
||||
vue-component-type-helpers@3.2.6:
|
||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
||||
|
||||
vue-component-type-helpers@3.2.7:
|
||||
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
|
||||
vue-component-type-helpers@3.2.8:
|
||||
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -13405,7 +13430,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.2.7
|
||||
vue-component-type-helpers: 3.2.8
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -13486,8 +13511,22 @@ snapshots:
|
||||
tailwindcss: 4.2.0
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
dependencies:
|
||||
remove-accents: 0.5.0
|
||||
|
||||
'@tanstack/query-core@5.100.9': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
'@tanstack/vue-query@5.100.9(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/match-sorter-utils': 8.19.4
|
||||
'@tanstack/query-core': 5.100.9
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.3))
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.12
|
||||
@@ -14189,7 +14228,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -19016,6 +19055,8 @@ snapshots:
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
unified: 11.0.5
|
||||
|
||||
remove-accents@0.5.0: {}
|
||||
|
||||
request-light@0.5.8: {}
|
||||
|
||||
request-light@0.7.0: {}
|
||||
@@ -20530,7 +20571,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.6: {}
|
||||
|
||||
vue-component-type-helpers@3.2.7: {}
|
||||
vue-component-type-helpers@3.2.8: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -37,6 +37,7 @@ catalog:
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tanstack/vue-query': ^5.100.9
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { QueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const inputAssetsIncludingPublicQueryPrefix = ['assets', 'input'] as const
|
||||
let inputAssetsIncludingPublicGeneration = 0
|
||||
|
||||
export function invalidateInputAssetsIncludingPublicQuery(): void {
|
||||
inputAssetsIncludingPublicGeneration++
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: inputAssetsIncludingPublicQueryPrefix,
|
||||
refetchType: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchInputAssetsIncludingPublicQuery(
|
||||
fetchAssets: () => Promise<AssetItem[]>
|
||||
): Promise<AssetItem[]> {
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: [
|
||||
...inputAssetsIncludingPublicQueryPrefix,
|
||||
{ includePublic: true, generation: inputAssetsIncludingPublicGeneration }
|
||||
],
|
||||
queryFn: fetchAssets,
|
||||
retry: false,
|
||||
staleTime: Infinity
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zListAssetsResponse } from '@comfyorg/ingest-types/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
|
||||
@@ -21,9 +22,9 @@ const zAsset = z.object({
|
||||
})
|
||||
|
||||
const zAssetResponse = z.object({
|
||||
assets: z.array(zAsset).optional(),
|
||||
total: z.number().optional(),
|
||||
has_more: z.boolean().optional()
|
||||
assets: z.array(zAsset),
|
||||
total: zListAssetsResponse.shape.total,
|
||||
has_more: zListAssetsResponse.shape.has_more
|
||||
})
|
||||
|
||||
const zModelFolder = z.object({
|
||||
|
||||
@@ -64,6 +64,16 @@ function buildResponse(
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function buildAssetListResponse(
|
||||
assets: AssetItem[],
|
||||
{
|
||||
hasMore = false,
|
||||
total = assets.length
|
||||
}: { hasMore?: boolean; total?: number } = {}
|
||||
): Response {
|
||||
return buildResponse({ assets, total, has_more: hasMore })
|
||||
}
|
||||
|
||||
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
@@ -218,7 +228,7 @@ describe(assetService.uploadAssetFromUrl, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -240,7 +250,7 @@ describe(assetService.uploadAssetFromUrl, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
|
||||
)
|
||||
@@ -301,7 +311,7 @@ describe(assetService.uploadAssetFromBase64, () => {
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -327,7 +337,7 @@ describe(assetService.uploadAssetFromBase64, () => {
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
|
||||
@@ -423,15 +433,13 @@ describe(assetService.getAssetModelFolders, () => {
|
||||
|
||||
it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'a', tags: ['models', 'loras'] }),
|
||||
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
|
||||
validAsset({ id: 'c', tags: ['models', 'configs'] }),
|
||||
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
|
||||
validAsset({ id: 'e', tags: ['models', 'loras'] })
|
||||
]
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'a', tags: ['models', 'loras'] }),
|
||||
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
|
||||
validAsset({ id: 'c', tags: ['models', 'configs'] }),
|
||||
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
|
||||
validAsset({ id: 'e', tags: ['models', 'loras'] })
|
||||
])
|
||||
)
|
||||
|
||||
const folders = await assetService.getAssetModelFolders()
|
||||
@@ -492,12 +500,10 @@ describe(assetService.getAssetsByTag, () => {
|
||||
|
||||
it('forwards include_public=true by default and excludes missing-tagged assets', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
|
||||
]
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
|
||||
])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAssetsByTag('input')
|
||||
@@ -518,17 +524,16 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
it('paginates tagged asset requests with include_public=true', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
],
|
||||
{ hasMore: true }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'c', tags: ['input'] })]
|
||||
})
|
||||
buildAssetListResponse([validAsset({ id: 'c', tags: ['input'] })])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
@@ -553,17 +558,18 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
it('paginates from raw response size before filtering missing-tagged assets', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
|
||||
]
|
||||
})
|
||||
],
|
||||
{ hasMore: true }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'later-public', tags: ['input'] })
|
||||
])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
@@ -584,23 +590,22 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
it('honors has_more when walking tagged asset pages', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'first', tags: ['input'] }),
|
||||
validAsset({ id: 'second', tags: ['input'] })
|
||||
],
|
||||
has_more: true
|
||||
})
|
||||
{ hasMore: true }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
|
||||
has_more: false
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'later-public', tags: ['input'] })
|
||||
])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 3
|
||||
limit: 2
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['first', 'second', 'later-public'])
|
||||
@@ -614,12 +619,23 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('rejects tagged asset pages without has_more', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })],
|
||||
total: 1
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
assetService.getAllAssetsByTag('input', true, { limit: 2 })
|
||||
).rejects.toThrow(/Invalid asset response/)
|
||||
})
|
||||
|
||||
it('passes abort signals through paginated requests', async () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })]
|
||||
})
|
||||
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })])
|
||||
)
|
||||
|
||||
await assetService.getAllAssetsByTag('input', true, {
|
||||
@@ -636,12 +652,13 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
return buildResponse({
|
||||
assets: [
|
||||
return buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
],
|
||||
{ hasMore: true }
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -666,7 +683,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
validAsset({ id: 'user-input', tags: ['input'] }),
|
||||
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
|
||||
]
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
|
||||
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
|
||||
|
||||
const first = await assetService.getInputAssetsIncludingPublic()
|
||||
const second = await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -685,8 +702,8 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
@@ -696,69 +713,25 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not let one caller abort the shared input asset load for other callers', async () => {
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
it('deduplicates concurrent input asset loads while the request is pending', async () => {
|
||||
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
|
||||
let resolveResponse!: (response: Response) => void
|
||||
let serviceSignal: AbortSignal | undefined
|
||||
fetchApiMock.mockImplementationOnce(async (_url, options) => {
|
||||
serviceSignal = options?.signal ?? undefined
|
||||
fetchApiMock.mockImplementationOnce(async () => {
|
||||
return await new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
})
|
||||
|
||||
const first = assetService.getInputAssetsIncludingPublic(
|
||||
firstController.signal
|
||||
)
|
||||
const second = assetService.getInputAssetsIncludingPublic(
|
||||
secondController.signal
|
||||
)
|
||||
firstController.abort()
|
||||
const first = assetService.getInputAssetsIncludingPublic()
|
||||
const second = assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(serviceSignal).toBeUndefined()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
resolveResponse(buildAssetListResponse(assets))
|
||||
|
||||
await expect(first).resolves.toEqual(assets)
|
||||
await expect(second).resolves.toEqual(assets)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the shared input asset load alive after all callers abort', async () => {
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
|
||||
let resolveResponse!: (response: Response) => void
|
||||
fetchApiMock.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = assetService.getInputAssetsIncludingPublic(
|
||||
firstController.signal
|
||||
)
|
||||
const second = assetService.getInputAssetsIncludingPublic(
|
||||
secondController.signal
|
||||
)
|
||||
firstController.abort()
|
||||
secondController.abort()
|
||||
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
await Promise.resolve()
|
||||
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
assets
|
||||
)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not abort in-flight input asset loads when invalidated', async () => {
|
||||
const assets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
@@ -770,12 +743,12 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
const inFlight = assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
resolveResponse(buildAssetListResponse(assets))
|
||||
|
||||
await expect(inFlight).resolves.toEqual(assets)
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
@@ -788,9 +761,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse(null))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.deleteAsset('stale-input')
|
||||
@@ -809,9 +782,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
|
||||
const freshAssets = [uploadedAsset]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse(uploadedAsset))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.uploadAssetAsync({
|
||||
@@ -827,7 +800,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
it('does not invalidate cached input assets for pending async input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(
|
||||
{ task_id: 'task-1', status: 'running' },
|
||||
@@ -849,7 +822,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
it('does not invalidate cached input assets for non-input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
|
||||
@@ -3,6 +3,10 @@ import { z } from 'zod'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
|
||||
import {
|
||||
fetchInputAssetsIncludingPublicQuery,
|
||||
invalidateInputAssetsIncludingPublicQuery
|
||||
} from '@/platform/assets/queries/inputAssetsIncludingPublicQuery'
|
||||
import {
|
||||
assetItemSchema,
|
||||
assetResponseSchema,
|
||||
@@ -202,35 +206,6 @@ export function toBlake3AssetHash(hash: string | undefined): string | null {
|
||||
return `blake3:${hash}`
|
||||
}
|
||||
|
||||
function createAbortError(): DOMException {
|
||||
return new DOMException('Aborted', 'AbortError')
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw createAbortError()
|
||||
}
|
||||
|
||||
async function withCallerAbort<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
throwIfAborted(signal)
|
||||
if (!signal) return await promise
|
||||
|
||||
let removeAbortListener = () => {}
|
||||
const abortPromise = new Promise<never>((_, reject) => {
|
||||
const onAbort = () => reject(createAbortError())
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
removeAbortListener = () => signal.removeEventListener('abort', onAbort)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, abortPromise])
|
||||
} finally {
|
||||
removeAbortListener()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates asset response data using Zod schema
|
||||
*/
|
||||
@@ -266,15 +241,9 @@ function validateUploadedAssetResponse(
|
||||
* Not exposed globally - used internally by ComfyApi
|
||||
*/
|
||||
function createAssetService() {
|
||||
let inputAssetsIncludingPublic: AssetItem[] | null = null
|
||||
let inputAssetsIncludingPublicRequestId = 0
|
||||
let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null
|
||||
|
||||
/** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */
|
||||
function invalidateInputAssetsIncludingPublic(): void {
|
||||
inputAssetsIncludingPublicRequestId++
|
||||
pendingInputAssetsIncludingPublic = null
|
||||
inputAssetsIncludingPublic = null
|
||||
invalidateInputAssetsIncludingPublicQuery()
|
||||
}
|
||||
|
||||
function invalidateInputAssetsCacheIfNeeded(tags?: string[]): void {
|
||||
@@ -473,11 +442,8 @@ function createAssetService() {
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
// Validate the single asset response against our schema
|
||||
const result = assetResponseSchema.safeParse({ assets: [data] })
|
||||
if (result.success && result.data.assets?.[0]) {
|
||||
return result.data.assets[0]
|
||||
}
|
||||
const result = assetItemSchema.safeParse(data)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = result.error
|
||||
? fromZodError(result.error)
|
||||
@@ -533,7 +499,7 @@ function createAssetService() {
|
||||
let offset = 0
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) throw createAbortError()
|
||||
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||
|
||||
const data = await handleAssetRequest(
|
||||
{
|
||||
@@ -548,10 +514,7 @@ function createAssetService() {
|
||||
const batch = data.assets ?? []
|
||||
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
|
||||
|
||||
const noMoreFromServer = data.has_more === false
|
||||
const inferredLastPage =
|
||||
data.has_more === undefined && batch.length < pageSize
|
||||
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
|
||||
if (batch.length === 0 || !data.has_more) {
|
||||
return assets
|
||||
}
|
||||
|
||||
@@ -559,43 +522,19 @@ function createAssetService() {
|
||||
}
|
||||
}
|
||||
|
||||
function startInputAssetsIncludingPublicRequest(): Promise<AssetItem[]> {
|
||||
const requestId = ++inputAssetsIncludingPublicRequestId
|
||||
|
||||
pendingInputAssetsIncludingPublic = getAllAssetsByTag('input', true, {
|
||||
limit: INPUT_ASSETS_WITH_PUBLIC_LIMIT
|
||||
})
|
||||
.then((assets) => {
|
||||
if (requestId === inputAssetsIncludingPublicRequestId) {
|
||||
inputAssetsIncludingPublic = assets
|
||||
}
|
||||
return assets
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === inputAssetsIncludingPublicRequestId) {
|
||||
pendingInputAssetsIncludingPublic = null
|
||||
}
|
||||
})
|
||||
|
||||
void pendingInputAssetsIncludingPublic.catch(() => {})
|
||||
return pendingInputAssetsIncludingPublic
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cached input assets including public assets for missing media checks.
|
||||
* Caller aborts cancel only that caller; shared fetches are invalidated
|
||||
* through invalidateInputAssetsIncludingPublic().
|
||||
* The signal parameter is accepted for existing callers; callers guard aborted
|
||||
* scan results outside this shared query cache.
|
||||
*/
|
||||
async function getInputAssetsIncludingPublic(
|
||||
signal?: AbortSignal
|
||||
_signal?: AbortSignal
|
||||
): Promise<AssetItem[]> {
|
||||
throwIfAborted(signal)
|
||||
if (inputAssetsIncludingPublic) return inputAssetsIncludingPublic
|
||||
|
||||
const request =
|
||||
pendingInputAssetsIncludingPublic ??
|
||||
startInputAssetsIncludingPublicRequest()
|
||||
return await withCallerAbort(request, signal)
|
||||
return await fetchInputAssetsIncludingPublicQuery(() =>
|
||||
getAllAssetsByTag('input', true, {
|
||||
limit: INPUT_ASSETS_WITH_PUBLIC_LIMIT
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
140
src/platform/assets/utils/assetHashVerification.test.ts
Normal file
140
src/platform/assets/utils/assetHashVerification.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { verifyCandidatesByAssetHash } from './assetHashVerification'
|
||||
|
||||
interface Candidate {
|
||||
id: string
|
||||
hash: string | null
|
||||
}
|
||||
|
||||
function candidate(id: string, hash: string | null): Candidate {
|
||||
return { id, hash }
|
||||
}
|
||||
|
||||
describe(verifyCandidatesByAssetHash, () => {
|
||||
it('deduplicates hash checks and groups existing and missing candidates', async () => {
|
||||
const existingHash = 'blake3:existing'
|
||||
const missingHash = 'blake3:missing'
|
||||
const candidates = [
|
||||
candidate('a', existingHash),
|
||||
candidate('b', existingHash),
|
||||
candidate('c', missingHash)
|
||||
]
|
||||
const checkAssetHash = vi.fn(async (hash: string) =>
|
||||
hash === existingHash ? ('exists' as const) : ('missing' as const)
|
||||
)
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash
|
||||
})
|
||||
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(result.existing.map((candidate) => candidate.id)).toEqual(['a', 'b'])
|
||||
expect(result.missing.map((candidate) => candidate.id)).toEqual(['c'])
|
||||
expect(result.fallback).toEqual([])
|
||||
expect(checkAssetHash).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('routes candidates without hashes and invalid hashes to fallback', async () => {
|
||||
const invalidHash = 'blake3:invalid'
|
||||
const candidates = [candidate('a', null), candidate('b', invalidHash)]
|
||||
const checkAssetHash = vi.fn(async () => 'invalid' as const)
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash
|
||||
})
|
||||
|
||||
expect(result.existing).toEqual([])
|
||||
expect(result.missing).toEqual([])
|
||||
expect(result.fallback.map((candidate) => candidate.id)).toEqual(['a', 'b'])
|
||||
expect(checkAssetHash).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('routes non-abort verification failures to fallback', async () => {
|
||||
const candidates = [candidate('a', 'blake3:network-failure')]
|
||||
const error = new Error('network failed')
|
||||
const onError = vi.fn()
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash: async () => {
|
||||
throw error
|
||||
},
|
||||
onError
|
||||
})
|
||||
|
||||
expect(result.fallback).toEqual(candidates)
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(onError).toHaveBeenCalledWith(error)
|
||||
})
|
||||
|
||||
it('returns aborted without resolving candidates when the signal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const candidates = [candidate('a', 'blake3:aborted')]
|
||||
const checkAssetHash = vi.fn(async () => 'exists' as const)
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
existing: [],
|
||||
missing: [],
|
||||
fallback: [],
|
||||
aborted: true
|
||||
})
|
||||
expect(checkAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silences abort errors from hash verification', async () => {
|
||||
const candidates = [candidate('a', 'blake3:aborted')]
|
||||
const onError = vi.fn()
|
||||
|
||||
const result = await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
checkAssetHash: async () => {
|
||||
throw new DOMException('aborted', 'AbortError')
|
||||
},
|
||||
onError
|
||||
})
|
||||
|
||||
expect(result.aborted).toBe(true)
|
||||
expect(result.existing).toEqual([])
|
||||
expect(result.missing).toEqual([])
|
||||
expect(result.fallback).toEqual([])
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps concurrent hash checks', async () => {
|
||||
let activeChecks = 0
|
||||
let maxActiveChecks = 0
|
||||
const candidates = Array.from({ length: 6 }, (_, index) =>
|
||||
candidate(String(index), `blake3:${index}`)
|
||||
)
|
||||
|
||||
await verifyCandidatesByAssetHash({
|
||||
candidates,
|
||||
getAssetHash: (candidate) => candidate.hash,
|
||||
maxConcurrent: 2,
|
||||
checkAssetHash: async () => {
|
||||
activeChecks++
|
||||
maxActiveChecks = Math.max(maxActiveChecks, activeChecks)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
activeChecks--
|
||||
return 'missing'
|
||||
}
|
||||
})
|
||||
|
||||
expect(maxActiveChecks).toBeLessThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
111
src/platform/assets/utils/assetHashVerification.ts
Normal file
111
src/platform/assets/utils/assetHashVerification.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
export type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
interface AssetHashVerificationResult<T> {
|
||||
existing: T[]
|
||||
missing: T[]
|
||||
fallback: T[]
|
||||
aborted: boolean
|
||||
}
|
||||
|
||||
interface VerifyCandidatesByAssetHashOptions<T> {
|
||||
candidates: readonly T[]
|
||||
getAssetHash: (candidate: T) => string | null
|
||||
checkAssetHash: AssetHashVerifier
|
||||
signal?: AbortSignal
|
||||
maxConcurrent?: number
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_CONCURRENT_HASH_CHECKS = 12
|
||||
|
||||
export async function verifyCandidatesByAssetHash<T>({
|
||||
candidates,
|
||||
getAssetHash,
|
||||
checkAssetHash,
|
||||
signal,
|
||||
maxConcurrent = DEFAULT_MAX_CONCURRENT_HASH_CHECKS,
|
||||
onError
|
||||
}: VerifyCandidatesByAssetHashOptions<T>): Promise<
|
||||
AssetHashVerificationResult<T>
|
||||
> {
|
||||
const result: AssetHashVerificationResult<T> = {
|
||||
existing: [],
|
||||
missing: [],
|
||||
fallback: [],
|
||||
aborted: false
|
||||
}
|
||||
|
||||
if (signal?.aborted) return { ...result, aborted: true }
|
||||
|
||||
const candidatesByHash = new Map<string, T[]>()
|
||||
for (const candidate of candidates) {
|
||||
const assetHash = getAssetHash(candidate)
|
||||
if (!assetHash) {
|
||||
result.fallback.push(candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashCandidates = candidatesByHash.get(assetHash)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(assetHash, [candidate])
|
||||
}
|
||||
|
||||
const entries = [...candidatesByHash.entries()]
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.min(
|
||||
entries.length,
|
||||
Math.max(1, Math.floor(maxConcurrent))
|
||||
)
|
||||
|
||||
async function verifyNextHash(): Promise<void> {
|
||||
while (!result.aborted && nextIndex < entries.length) {
|
||||
const entry = entries[nextIndex++]
|
||||
if (!entry) return
|
||||
|
||||
const [assetHash, hashCandidates] = entry
|
||||
if (signal?.aborted) {
|
||||
result.aborted = true
|
||||
return
|
||||
}
|
||||
|
||||
let status: AssetHashStatus
|
||||
try {
|
||||
status = await checkAssetHash(assetHash, signal)
|
||||
} catch (error) {
|
||||
if (signal?.aborted || isAbortError(error)) {
|
||||
result.aborted = true
|
||||
return
|
||||
}
|
||||
|
||||
onError?.(error)
|
||||
result.fallback.push(...hashCandidates)
|
||||
continue
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
result.aborted = true
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'exists') {
|
||||
result.existing.push(...hashCandidates)
|
||||
} else if (status === 'missing') {
|
||||
result.missing.push(...hashCandidates)
|
||||
} else {
|
||||
result.fallback.push(...hashCandidates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => await verifyNextHash())
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -403,8 +403,7 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
})
|
||||
|
||||
it('silences aborts while loading legacy fallback input assets', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const abortError = new DOMException('aborted', 'AbortError')
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
@@ -427,8 +426,7 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
})
|
||||
|
||||
it('silences aborts from the default legacy fallback input asset store path', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const abortError = new DOMException('aborted', 'AbortError')
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
|
||||
@@ -19,11 +19,13 @@ import {
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
assetService,
|
||||
isBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import { verifyCandidatesByAssetHash } from '@/platform/assets/utils/assetHashVerification'
|
||||
import type { AssetHashVerifier } from '@/platform/assets/utils/assetHashVerification'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
/** Map of node types to their media widget name and media type. */
|
||||
const MEDIA_NODE_WIDGETS: Record<
|
||||
@@ -112,70 +114,8 @@ export function scanNodeMediaCandidates(
|
||||
return candidates
|
||||
}
|
||||
|
||||
type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]>
|
||||
|
||||
function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
|
||||
candidatesByHash: Map<string, MissingMediaCandidate[]>
|
||||
legacyCandidates: MissingMediaCandidate[]
|
||||
} {
|
||||
const candidatesByHash = new Map<string, MissingMediaCandidate[]>()
|
||||
const legacyCandidates: MissingMediaCandidate[] = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!isBlake3AssetHash(candidate.name)) {
|
||||
legacyCandidates.push(candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashCandidates = candidatesByHash.get(candidate.name)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(candidate.name, [candidate])
|
||||
}
|
||||
|
||||
return { candidatesByHash, legacyCandidates }
|
||||
}
|
||||
|
||||
async function verifyCandidatesByHash(
|
||||
candidatesByHash: Map<string, MissingMediaCandidate[]>,
|
||||
legacyCandidates: MissingMediaCandidate[],
|
||||
signal: AbortSignal | undefined,
|
||||
checkAssetHash: AssetHashVerifier
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
|
||||
if (signal?.aborted) return
|
||||
|
||||
let status: AssetHashStatus
|
||||
try {
|
||||
status = await checkAssetHash(assetHash, signal)
|
||||
if (signal?.aborted) return
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
console.warn(
|
||||
'[Missing Media Pipeline] Failed to verify asset hash:',
|
||||
err
|
||||
)
|
||||
legacyCandidates.push(...hashCandidates)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'invalid') {
|
||||
legacyCandidates.push(...hashCandidates)
|
||||
return
|
||||
}
|
||||
|
||||
for (const candidate of hashCandidates) {
|
||||
candidate.isMissing = status === 'missing'
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cloud media candidates by probing the asset hash endpoint first.
|
||||
* Invalid hash values fall back to the legacy input asset list check.
|
||||
@@ -191,16 +131,26 @@ export async function verifyCloudMediaCandidates(
|
||||
const pending = candidates.filter((c) => c.isMissing === undefined)
|
||||
if (pending.length === 0) return
|
||||
|
||||
const { candidatesByHash, legacyCandidates } =
|
||||
groupCandidatesForHashLookup(pending)
|
||||
await verifyCandidatesByHash(
|
||||
candidatesByHash,
|
||||
legacyCandidates,
|
||||
const verification = await verifyCandidatesByAssetHash({
|
||||
candidates: pending,
|
||||
getAssetHash: (candidate) =>
|
||||
isBlake3AssetHash(candidate.name) ? candidate.name : null,
|
||||
signal,
|
||||
checkAssetHash
|
||||
)
|
||||
checkAssetHash,
|
||||
onError: (err) => {
|
||||
console.warn('[Missing Media Pipeline] Failed to verify asset hash:', err)
|
||||
}
|
||||
})
|
||||
if (verification.aborted) return
|
||||
|
||||
if (signal?.aborted || legacyCandidates.length === 0) return
|
||||
for (const candidate of verification.existing) {
|
||||
candidate.isMissing = false
|
||||
}
|
||||
for (const candidate of verification.missing) {
|
||||
candidate.isMissing = true
|
||||
}
|
||||
|
||||
if (signal?.aborted || verification.fallback.length === 0) return
|
||||
|
||||
let inputAssets: AssetItem[]
|
||||
try {
|
||||
@@ -216,7 +166,7 @@ export async function verifyCloudMediaCandidates(
|
||||
inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
||||
)
|
||||
|
||||
for (const candidate of legacyCandidates) {
|
||||
for (const candidate of verification.fallback) {
|
||||
candidate.isMissing = !assetHashes.has(candidate.name)
|
||||
}
|
||||
}
|
||||
@@ -227,15 +177,6 @@ async function fetchMissingInputAssets(
|
||||
return await assetService.getInputAssetsIncludingPublic(signal)
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
err.name === 'AbortError'
|
||||
)
|
||||
}
|
||||
|
||||
/** Group confirmed-missing candidates by file name into view models. */
|
||||
export function groupCandidatesByName(
|
||||
candidates: MissingMediaCandidate[]
|
||||
|
||||
@@ -1557,8 +1557,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
|
||||
it('should not warn or fall back when hash verification is aborted', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const abortError = new DOMException('aborted', 'AbortError')
|
||||
const hash =
|
||||
'4444444444444444444444444444444444444444444444444444444444444444'
|
||||
const candidates = [
|
||||
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
assetService,
|
||||
toBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import { verifyCandidatesByAssetHash } from '@/platform/assets/utils/assetHashVerification'
|
||||
import type { AssetHashVerifier } from '@/platform/assets/utils/assetHashVerification'
|
||||
|
||||
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
|
||||
models?: ModelFile[]
|
||||
@@ -450,11 +451,6 @@ interface AssetVerifier {
|
||||
getAssets: (nodeType: string) => AssetItem[] | undefined
|
||||
}
|
||||
|
||||
type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
export async function verifyAssetSupportedCandidates(
|
||||
candidates: MissingModelCandidate[],
|
||||
signal?: AbortSignal,
|
||||
@@ -468,47 +464,25 @@ export async function verifyAssetSupportedCandidates(
|
||||
)
|
||||
if (pendingCandidates.length === 0) return
|
||||
|
||||
const pendingNodeTypes = new Set<string>()
|
||||
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
|
||||
|
||||
for (const candidate of pendingCandidates) {
|
||||
const assetHash = getBlake3AssetHash(candidate)
|
||||
if (!assetHash) {
|
||||
pendingNodeTypes.add(candidate.nodeType)
|
||||
continue
|
||||
const verification = await verifyCandidatesByAssetHash({
|
||||
candidates: pendingCandidates,
|
||||
getAssetHash: getBlake3AssetHash,
|
||||
signal,
|
||||
checkAssetHash,
|
||||
onError: (err) => {
|
||||
console.warn('[Missing Model Pipeline] Failed to verify asset hash:', err)
|
||||
}
|
||||
})
|
||||
if (verification.aborted) return
|
||||
|
||||
const hashCandidates = candidatesByHash.get(assetHash)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(assetHash, [candidate])
|
||||
for (const candidate of verification.existing) {
|
||||
candidate.isMissing = false
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
|
||||
if (signal?.aborted) return
|
||||
|
||||
try {
|
||||
const status = await checkAssetHash(assetHash, signal)
|
||||
if (signal?.aborted) return
|
||||
|
||||
if (status === 'exists') {
|
||||
for (const candidate of hashCandidates) {
|
||||
candidate.isMissing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
console.warn(
|
||||
'[Missing Model Pipeline] Failed to verify asset hash:',
|
||||
err
|
||||
)
|
||||
}
|
||||
|
||||
for (const candidate of hashCandidates) {
|
||||
pendingNodeTypes.add(candidate.nodeType)
|
||||
}
|
||||
})
|
||||
const pendingNodeTypes = new Set(
|
||||
[...verification.missing, ...verification.fallback].map(
|
||||
(candidate) => candidate.nodeType
|
||||
)
|
||||
)
|
||||
|
||||
if (signal?.aborted) return
|
||||
@@ -549,15 +523,6 @@ function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
|
||||
return toBlake3AssetHash(candidate.hash)
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
err.name === 'AbortError'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -273,10 +274,6 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
fileSizes.value = {}
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
async function refreshMissingModels() {
|
||||
if (isRefreshingMissingModels.value) return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user