mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
fix: enable 3D thumbnail support for cloud environments (#10121)
## Summary
The 3D thumbnail logic was gated behind `api.getServerFeature('assets',
false)` which only covers local servers. Use `isAssetPreviewSupported()`
to also cover cloud via `assetService.isAssetAPIEnabled()`.
follow up https://github.com/Comfy-Org/ComfyUI_frontend/pull/9471
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10121-fix-enable-3D-thumbnail-support-for-cloud-environments-3256d73d365081c18975e917c604582b)
by [Unito](https://www.unito.io)
This commit is contained in:
@@ -5,7 +5,10 @@ import { nextTick, ref, toRaw, watch } from 'vue'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import type {
|
||||
AnimationItem,
|
||||
CameraConfig,
|
||||
@@ -515,7 +518,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
// Reset skeleton visibility when loading new model
|
||||
modelConfig.value.showSkeleton = false
|
||||
|
||||
if (load3d && api.getServerFeature('assets', false)) {
|
||||
if (load3d && isAssetPreviewSupported()) {
|
||||
const node = nodeRef.value
|
||||
|
||||
const modelWidget = node?.widgets?.find(
|
||||
|
||||
@@ -7,14 +7,16 @@ import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
type SaveMeshOutput = NodeOutputWith<{
|
||||
'3d'?: ResultItem[]
|
||||
}>
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
@@ -103,7 +105,7 @@ useExtensionService().registerExtension({
|
||||
|
||||
config.configureForSaveMesh(loadFolder, filePath)
|
||||
|
||||
if (api.getServerFeature('assets', false)) {
|
||||
if (isAssetPreviewSupported()) {
|
||||
const filename = fileInfo.filename ?? ''
|
||||
const onModelLoaded = () => {
|
||||
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
|
||||
|
||||
@@ -22,10 +22,11 @@
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { findServerPreviewUrl } from '../utils/assetPreviewUtil'
|
||||
import {
|
||||
findServerPreviewUrl,
|
||||
isAssetPreviewSupported
|
||||
} from '../utils/assetPreviewUtil'
|
||||
|
||||
const { asset } = defineProps<{ asset: AssetMeta }>()
|
||||
|
||||
@@ -48,7 +49,7 @@ async function loadThumbnail() {
|
||||
|
||||
if (!asset?.src) return
|
||||
|
||||
if (asset.name && api.getServerFeature('assets', false)) {
|
||||
if (asset.name && isAssetPreviewSupported()) {
|
||||
const serverPreviewUrl = await findServerPreviewUrl(asset.name)
|
||||
if (serverPreviewUrl) {
|
||||
thumbnailSrc.value = serverPreviewUrl
|
||||
|
||||
267
src/platform/assets/utils/assetPreviewUtil.test.ts
Normal file
267
src/platform/assets/utils/assetPreviewUtil.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
findOutputAsset,
|
||||
findServerPreviewUrl,
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
|
||||
const mockFetchApi = vi.hoisted(() => vi.fn())
|
||||
const mockApiURL = vi.hoisted(() =>
|
||||
vi.fn((path: string) => `http://localhost:8188${path}`)
|
||||
)
|
||||
const mockGetServerFeature = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockUploadAssetFromBase64 = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateAsset = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: mockFetchApi,
|
||||
apiURL: mockApiURL,
|
||||
api_base: '',
|
||||
getServerFeature: mockGetServerFeature
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
isAssetAPIEnabled: mockIsAssetAPIEnabled,
|
||||
uploadAssetFromBase64: mockUploadAssetFromBase64,
|
||||
updateAsset: mockUpdateAsset
|
||||
}
|
||||
}))
|
||||
|
||||
function mockFetchResponse(assets: Record<string, unknown>[]) {
|
||||
mockFetchApi.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ assets })
|
||||
})
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
mockFetchResponse([])
|
||||
}
|
||||
|
||||
function mockFetchError() {
|
||||
mockFetchApi.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
|
||||
const cloudAsset = {
|
||||
id: '72d169cc-7f9a-40d2-9382-35eadcba0a6a',
|
||||
name: 'mesh/ComfyUI_00003_.glb',
|
||||
asset_hash: 'c6cadcee57dd.glb',
|
||||
preview_id: null,
|
||||
preview_url: undefined
|
||||
}
|
||||
|
||||
const cloudAssetWithPreview = {
|
||||
...cloudAsset,
|
||||
preview_id: 'aaaa-bbbb',
|
||||
preview_url: '/api/view?type=output&filename=preview.png'
|
||||
}
|
||||
|
||||
const localAsset = {
|
||||
id: '50bf419e-7ecb-4c96-a0c7-c1eb4dff00cb',
|
||||
name: 'ComfyUI_00081_.glb',
|
||||
preview_id: null,
|
||||
preview_url:
|
||||
'/api/view?type=output&filename=ComfyUI_00081_.glb&subfolder=mesh'
|
||||
}
|
||||
|
||||
const localAssetWithPreview = {
|
||||
...localAsset,
|
||||
preview_id: '3df94ee8-preview',
|
||||
preview_url: '/api/view?type=output&filename=preview.png'
|
||||
}
|
||||
|
||||
describe('isAssetPreviewSupported', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns true when asset API is enabled (cloud)', () => {
|
||||
mockIsAssetAPIEnabled.mockReturnValue(true)
|
||||
expect(isAssetPreviewSupported()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when server assets feature is enabled (local)', () => {
|
||||
mockGetServerFeature.mockReturnValue(true)
|
||||
expect(isAssetPreviewSupported()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when neither is enabled', () => {
|
||||
mockIsAssetAPIEnabled.mockReturnValue(false)
|
||||
mockGetServerFeature.mockReturnValue(false)
|
||||
expect(isAssetPreviewSupported()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findOutputAsset', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('finds asset by hash (cloud)', async () => {
|
||||
mockFetchResponse([cloudAsset])
|
||||
|
||||
const result = await findOutputAsset('c6cadcee57dd.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledOnce()
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain(
|
||||
'asset_hash=c6cadcee57dd.glb'
|
||||
)
|
||||
expect(result).toEqual(cloudAsset)
|
||||
})
|
||||
|
||||
it('falls back to name_contains when hash returns empty (local)', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([localAsset])
|
||||
|
||||
const result = await findOutputAsset('ComfyUI_00081_.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_hash=')
|
||||
expect(mockFetchApi.mock.calls[1][0]).toContain('name_contains=')
|
||||
expect(result).toEqual(localAsset)
|
||||
})
|
||||
|
||||
it('returns undefined when no asset matches', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchEmpty()
|
||||
|
||||
const result = await findOutputAsset('nonexistent.glb')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('matches exact name from name_contains results', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([
|
||||
{ id: '1', name: 'ComfyUI_00001_.glb_preview.png' },
|
||||
{ id: '2', name: 'ComfyUI_00001_.glb' }
|
||||
])
|
||||
|
||||
const result = await findOutputAsset('ComfyUI_00001_.glb')
|
||||
expect(result?.id).toBe('2')
|
||||
})
|
||||
|
||||
it('returns empty array on fetch error', async () => {
|
||||
mockFetchError()
|
||||
mockFetchError()
|
||||
|
||||
const result = await findOutputAsset('test.glb')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findServerPreviewUrl', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns null when asset has no preview_id', async () => {
|
||||
mockFetchResponse([cloudAsset])
|
||||
|
||||
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns preview_url via apiURL when preview_id is set', async () => {
|
||||
mockFetchResponse([cloudAssetWithPreview])
|
||||
|
||||
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
|
||||
|
||||
expect(mockApiURL).toHaveBeenCalledWith(cloudAssetWithPreview.preview_url)
|
||||
expect(result).toBe(
|
||||
`http://localhost:8188${cloudAssetWithPreview.preview_url}`
|
||||
)
|
||||
})
|
||||
|
||||
it('constructs URL from preview_id when preview_url is missing', async () => {
|
||||
mockFetchResponse([{ ...cloudAsset, preview_id: 'aaaa-bbbb' }])
|
||||
|
||||
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
|
||||
expect(result).toBe('http://localhost:8188/assets/aaaa-bbbb/content')
|
||||
})
|
||||
|
||||
it('falls back to asset id when preview_id is null but set', async () => {
|
||||
// Edge case: asset has preview_id explicitly null, no preview_url
|
||||
mockFetchEmpty()
|
||||
mockFetchEmpty()
|
||||
|
||||
const result = await findServerPreviewUrl('nonexistent.glb')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null on error', async () => {
|
||||
mockFetchApi.mockRejectedValueOnce(new Error('network error'))
|
||||
|
||||
const result = await findServerPreviewUrl('test.glb')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistThumbnail', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uploads thumbnail and links preview_id', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([localAsset])
|
||||
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
|
||||
mockUpdateAsset.mockResolvedValue({})
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await persistThumbnail('ComfyUI_00081_.glb', blob)
|
||||
|
||||
expect(mockUploadAssetFromBase64).toHaveBeenCalledOnce()
|
||||
expect(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
|
||||
'ComfyUI_00081_.glb_preview.png'
|
||||
)
|
||||
expect(mockUpdateAsset).toHaveBeenCalledWith(localAsset.id, {
|
||||
preview_id: 'new-preview-id'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips when asset already has preview_id', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([localAssetWithPreview])
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await persistThumbnail('ComfyUI_00081_.glb', blob)
|
||||
|
||||
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips when no asset found', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchEmpty()
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await persistThumbnail('nonexistent.glb', blob)
|
||||
|
||||
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows errors silently', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([localAsset])
|
||||
mockUploadAssetFromBase64.mockRejectedValue(new Error('upload failed'))
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await expect(
|
||||
persistThumbnail('ComfyUI_00081_.glb', blob)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('works with cloud hash filename', async () => {
|
||||
mockFetchResponse([cloudAsset])
|
||||
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
|
||||
mockUpdateAsset.mockResolvedValue({})
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await persistThumbnail('c6cadcee57dd.glb', blob)
|
||||
|
||||
expect(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
|
||||
'mesh/ComfyUI_00003_.glb_preview.png'
|
||||
)
|
||||
expect(mockUpdateAsset).toHaveBeenCalledWith(cloudAsset.id, {
|
||||
preview_id: 'new-preview-id'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,50 +1,75 @@
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { assetService } from '../services/assetService'
|
||||
|
||||
interface AssetRecord {
|
||||
id: string
|
||||
name: string
|
||||
asset_hash?: string
|
||||
preview_url?: string
|
||||
preview_id?: string
|
||||
preview_id?: string | null
|
||||
}
|
||||
|
||||
async function fetchAssetsByName(name: string): Promise<AssetRecord[]> {
|
||||
const params = new URLSearchParams({ name_contains: name })
|
||||
const res = await api.fetchApi(`/assets?${params}`)
|
||||
export function isAssetPreviewSupported(): boolean {
|
||||
return (
|
||||
assetService.isAssetAPIEnabled() || api.getServerFeature('assets', false)
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchAssets(
|
||||
params: Record<string, string>
|
||||
): Promise<AssetRecord[]> {
|
||||
const query = new URLSearchParams(params)
|
||||
const res = await api.fetchApi(`/assets?${query}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.assets ?? []
|
||||
}
|
||||
|
||||
function resolvePreviewUrl(asset: AssetRecord): string {
|
||||
if (asset.preview_url) return api.apiURL(asset.preview_url)
|
||||
|
||||
const contentId = asset.preview_id ?? asset.id
|
||||
return api.apiURL(`/assets/${contentId}/content`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an output asset record by content hash, falling back to name.
|
||||
* On cloud, output filenames are content-hashed; use asset_hash to match.
|
||||
* On local, filenames are not hashed; use name_contains to match.
|
||||
*/
|
||||
export async function findOutputAsset(
|
||||
name: string
|
||||
): Promise<AssetRecord | undefined> {
|
||||
const byHash = await fetchAssets({ asset_hash: name })
|
||||
const hashMatch = byHash.find((a) => a.asset_hash === name)
|
||||
if (hashMatch) return hashMatch
|
||||
|
||||
const byName = await fetchAssets({ name_contains: name })
|
||||
return byName.find((a) => a.name === name)
|
||||
}
|
||||
|
||||
export async function findServerPreviewUrl(
|
||||
name: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const assets = await fetchAssetsByName(name)
|
||||
const asset = await findOutputAsset(name)
|
||||
if (!asset?.preview_id) return null
|
||||
|
||||
const modelAsset = assets.find((a) => a.name === name)
|
||||
if (!modelAsset?.preview_id) return null
|
||||
|
||||
const previewAsset = assets.find((a) => a.id === modelAsset.preview_id)
|
||||
if (!previewAsset?.preview_url) return null
|
||||
|
||||
return api.api_base + previewAsset.preview_url
|
||||
return resolvePreviewUrl(asset)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistThumbnail(
|
||||
modelName: string,
|
||||
name: string,
|
||||
blob: Blob
|
||||
): Promise<void> {
|
||||
try {
|
||||
const assets = await fetchAssetsByName(modelName)
|
||||
const modelAsset = assets.find((a) => a.name === modelName)
|
||||
if (!modelAsset || modelAsset.preview_id) return
|
||||
const asset = await findOutputAsset(name)
|
||||
if (!asset || asset.preview_id) return
|
||||
|
||||
const previewFilename = `${modelName}_preview.png`
|
||||
const previewFilename = `${asset.name}_preview.png`
|
||||
const uploaded = await assetService.uploadAssetFromBase64({
|
||||
data: await blobToDataUrl(blob),
|
||||
name: previewFilename,
|
||||
@@ -52,7 +77,7 @@ export async function persistThumbnail(
|
||||
user_metadata: { filename: previewFilename }
|
||||
})
|
||||
|
||||
await assetService.updateAsset(modelAsset.id, {
|
||||
await assetService.updateAsset(asset.id, {
|
||||
preview_id: uploaded.id
|
||||
})
|
||||
} catch {
|
||||
|
||||
@@ -380,6 +380,7 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
|
||||
apiURL(route: string): string {
|
||||
if (route.startsWith('/api')) return this.api_base + route
|
||||
return this.api_base + '/api' + route
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user