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:
Terry Jia
2026-03-17 16:36:01 -04:00
committed by GitHub
parent 55f1081874
commit 2e5e04efd5
6 changed files with 328 additions and 29 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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