Compare commits

...

2 Commits

Author SHA1 Message Date
glary-bot
293fde098d fix(assets): address CodeRabbit review
- MediaImageTop.vue: replace forbidden `:class="[]"` array merge with the
  project's `cn()` utility per AGENTS.md.
- assetRenditions.test.ts: type the makeAsset fixture as
  Partial<AssetItem> + `satisfies AssetItem` instead of `as never`, so
  schema drift surfaces at compile time.
2026-05-20 03:46:33 +00:00
glary-bot
6f3bacf483 feat(assets): generalized rendition resolver + MIME-type icon mapping
Introduces a single helper that decides which URL to use per display
surface (grid / lightbox / new tab / download) using the priority chain
the team agreed on:

  grid:     thumbnail_url ?? preview_url ?? (renderable ? canonical : null)
  lightbox: preview_url ?? (renderable ? canonical : null)
  download: canonical (never a transcoded preview rendition)

Renderability is decided from `asset.mime_type` against a small allowlist
of browser-native MIME types; format policy lives in one place and the
helper is generalised so backends can extend the matrix (HEIC, RAW, TIFF,
PSD, etc.) without UI changes. EXR isn't named anywhere in the code path.

Three concrete fixes for the SaveImageAdvanced / EXR product case:

- MediaAssetCard now routes non-3D assets through the rendition resolver
  instead of an ad-hoc `thumbnail_url || preview_url || ''` chain, so
  non-browser-renderable originals fall through to an intentional
  placeholder instead of a broken-image state.

- useMediaAssetActions.downloadAssets no longer falls back to the
  preview rendition for downloads. Previously `preview_url ||
  getAssetUrl(asset)` would silently deliver an AVIF when the user
  clicked Download on an EXR; downloads now always hit the canonical
  asset URL. Two existing tests updated to match the corrected
  behaviour, plus a new test that codifies the EXR-with-AVIF case.

- MediaImageTop's load-error fallback shows a deliberate placeholder
  (MIME-aware lucide icon, filename, 'Preview not available') in place
  of the previous primeicons broken-image state.

iconForMimeType generalises iconForMediaType so any caller with an
authoritative MIME type gets a sensible file-family icon (image, video,
audio, text, model) without having to translate through MediaKind first.

Aligns with the planned /assets API contract: renditionFor() is the
single integration point that needs updating when the API gains
structured rendition fields, instead of touching 47 ad-hoc call sites.
2026-05-20 03:38:56 +00:00
9 changed files with 516 additions and 19 deletions

View File

@@ -3097,6 +3097,7 @@
}
},
"mediaAsset": {
"previewNotAvailable": "Preview not available",
"deleteAssetTitle": "Delete this asset?",
"deleteAssetDescription": "This asset will be permanently removed.",
"deleteSelectedTitle": "Delete selected assets?",

View File

@@ -161,6 +161,7 @@ import type { AssetItem } from '../schemas/assetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
import { renditionFor } from '../utils/assetRenditions'
import MediaTitle from './MediaTitle.vue'
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
@@ -224,7 +225,15 @@ const fileKind = computed((): MediaKind => {
return getMediaTypeFromFilename(asset?.name || '')
})
// Route by MIME family first so non-renderable image originals (e.g. EXR
// with an AVIF preview) still flow through MediaImageTop and pick up its
// rendition + placeholder logic instead of falling into the generic
// MediaOtherTop bucket based on file extension alone.
const previewKind = computed((): PreviewKind => {
const mimeFamily = asset?.mime_type?.toLowerCase().split('/')[0]
if (mimeFamily === 'image') return 'image'
if (mimeFamily === 'video') return 'video'
if (mimeFamily === 'audio') return 'audio'
return getMediaTypeFromFilename(asset?.name || '')
})
@@ -235,18 +244,24 @@ const fileName = computed(() => {
return getFilenameDetails(asset ? getAssetDisplayName(asset) : '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
// Adapt AssetItem to legacy AssetMeta format for existing components.
// `src` resolution differs by kind: 3D viewers need the canonical model URL
// (.glb/.obj); other previews want the thumbnail/preview rendition chain
// from renditionFor() so non-renderable originals (e.g. EXR) fall through
// to the icon placeholder in MediaImageTop instead of a broken <img>.
const adaptedAsset = computed(() => {
if (!asset) return undefined
const src =
fileKind.value === '3D'
? getAssetUrl(asset)
: (renditionFor(asset, 'grid') ?? '')
return {
id: asset.id,
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src:
fileKind.value === '3D'
? getAssetUrl(asset)
: asset.thumbnail_url || asset.preview_url || '',
src,
mime_type: asset.mime_type,
preview_url: asset.preview_url,
preview_id: asset.preview_id,
size: asset.size,

View File

@@ -4,7 +4,7 @@
@dblclick="emit('view')"
>
<img
v-if="!error"
v-if="showImage"
:src="asset.src"
:alt="getAssetDisplayName(asset)"
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
@@ -12,18 +12,28 @@
/>
<div
v-else
class="flex size-full items-center justify-center bg-modal-card-placeholder-background"
class="flex size-full flex-col items-center justify-center gap-2 bg-modal-card-placeholder-background p-3 text-center text-muted-foreground"
data-testid="media-image-placeholder"
>
<i class="pi pi-image text-3xl text-muted-foreground" />
<i :class="cn('size-8', placeholderIcon)" />
<span class="line-clamp-2 max-w-full text-xs break-all">
{{ getAssetDisplayName(asset) }}
</span>
<span class="text-2xs text-muted-foreground/70">
{{ $t('mediaAsset.previewNotAvailable') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useImage, whenever } from '@vueuse/core'
import { computed } from 'vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import { iconForMimeType } from '../utils/mediaIconUtil'
const { asset } = defineProps<{
asset: AssetMeta
@@ -39,6 +49,10 @@ const { state, error, isReady } = useImage({
alt: getAssetDisplayName(asset)
})
const showImage = computed(() => !error.value && !!asset.src)
const placeholderIcon = computed(() => iconForMimeType(asset.mime_type))
whenever(
() =>
isReady.value && state.value?.naturalWidth && state.value?.naturalHeight,

View File

@@ -487,7 +487,8 @@ describe('useMediaAssetActions', () => {
})
describe('downloadAssets', () => {
it('downloads the injected media asset when called without explicit assets', () => {
it('downloads the canonical asset (not the preview rendition) when called without explicit assets', () => {
mockGetAssetType.mockReturnValue('input')
const mediaAsset = createMockMediaAsset({
id: 'context-asset',
name: 'context-name.png',
@@ -499,10 +500,11 @@ describe('useMediaAssetActions', () => {
actions.downloadAssets()
expect(mockDownloadFile).toHaveBeenCalledOnce()
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/context-preview.png',
'Context image.png'
)
const [downloadUrl, downloadName] = mockDownloadFile.mock.calls[0]
expect(downloadUrl).toContain('filename=context-name.png')
expect(downloadUrl).toContain('type=input')
expect(downloadUrl).not.toBe('https://example.com/context-preview.png')
expect(downloadName).toBe('Context image.png')
expect(mockCreateAssetExport).not.toHaveBeenCalled()
expect(mockTrackExport).not.toHaveBeenCalled()
@@ -520,8 +522,31 @@ describe('useMediaAssetActions', () => {
unmount()
})
it('downloads the original EXR, not the AVIF preview, when both are present', () => {
mockGetAssetType.mockReturnValue('output')
const asset = createMockAsset({
id: 'exr-asset',
name: 'render.exr',
mime_type: 'image/aces',
tags: ['output'],
preview_url: 'https://example.com/render.avif',
thumbnail_url: 'https://example.com/render.avif'
})
const actions = useMediaAssetActions()
actions.downloadAssets([asset])
expect(mockDownloadFile).toHaveBeenCalledOnce()
const [downloadUrl, downloadName] = mockDownloadFile.mock.calls[0]
expect(downloadUrl).toContain('filename=render.exr')
expect(downloadUrl).toContain('type=output')
expect(downloadUrl).not.toContain('avif')
expect(downloadName).toBe('render.exr')
})
it('keeps single explicit assets on the direct download path in cloud', () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('output')
mockGetOutputAssetMetadata.mockReturnValue({
jobId: 'job1',
outputCount: 1
@@ -539,10 +564,10 @@ describe('useMediaAssetActions', () => {
actions.downloadAssets([asset])
expect(mockDownloadFile).toHaveBeenCalledOnce()
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/single-output.png',
'single-output.png'
)
const [downloadUrl, downloadName] = mockDownloadFile.mock.calls[0]
expect(downloadUrl).toContain('filename=single-output.png')
expect(downloadUrl).toContain('type=output')
expect(downloadName).toBe('single-output.png')
expect(mockCreateAssetExport).not.toHaveBeenCalled()
expect(mockTrackExport).not.toHaveBeenCalled()
})

View File

@@ -20,6 +20,7 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import { getAssetType } from '../utils/assetTypeUtil'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { renditionFor } from '../utils/assetRenditions'
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
@@ -125,7 +126,9 @@ export function useMediaAssetActions() {
try {
targetAssets.forEach((asset) => {
const filename = getAssetDisplayName(asset)
const downloadUrl = asset.preview_url || getAssetUrl(asset)
// Download the canonical asset, never a transcoded preview rendition.
const downloadUrl =
renditionFor(asset, 'download') ?? getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})

View File

@@ -0,0 +1,243 @@
import { describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
canRenderNatively,
renditionFor
} from '@/platform/assets/utils/assetRenditions'
vi.mock('@/platform/assets/utils/assetUrlUtil', () => ({
getAssetUrl: vi.fn(
(asset: { name: string }) => `/api/view?filename=${asset.name}&type=output`
)
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((route: string) =>
route.startsWith('/api')
? `http://host${route}`
: `http://host/api${route}`
)
}
}))
function makeAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'image.png',
tags: ['output'],
...overrides
} satisfies AssetItem
}
describe('canRenderNatively', () => {
it('accepts common browser-native image types', () => {
expect(canRenderNatively('image/png')).toBe(true)
expect(canRenderNatively('image/jpeg')).toBe(true)
expect(canRenderNatively('image/webp')).toBe(true)
expect(canRenderNatively('image/avif')).toBe(true)
expect(canRenderNatively('image/gif')).toBe(true)
expect(canRenderNatively('image/svg+xml')).toBe(true)
})
it('accepts browser-native video and audio types', () => {
expect(canRenderNatively('video/mp4')).toBe(true)
expect(canRenderNatively('video/webm')).toBe(true)
expect(canRenderNatively('audio/mpeg')).toBe(true)
expect(canRenderNatively('audio/wav')).toBe(true)
})
it('rejects non-browser-renderable image families (EXR, HDR, RAW, TIFF)', () => {
expect(canRenderNatively('image/aces')).toBe(false)
expect(canRenderNatively('image/x-exr')).toBe(false)
expect(canRenderNatively('image/x-hdr')).toBe(false)
expect(canRenderNatively('image/x-adobe-dng')).toBe(false)
expect(canRenderNatively('image/tiff')).toBe(false)
})
it('rejects opaque file types (latents, models, text)', () => {
expect(canRenderNatively('application/octet-stream')).toBe(false)
expect(canRenderNatively('model/gltf-binary')).toBe(false)
expect(canRenderNatively('text/plain')).toBe(false)
})
it('handles charset / parameter suffixes', () => {
expect(canRenderNatively('image/png; charset=utf-8')).toBe(true)
expect(canRenderNatively('IMAGE/PNG')).toBe(true)
})
it('returns false for null, undefined, and empty string', () => {
expect(canRenderNatively(null)).toBe(false)
expect(canRenderNatively(undefined)).toBe(false)
expect(canRenderNatively('')).toBe(false)
})
})
describe('renditionFor', () => {
describe('grid surface', () => {
it('prefers thumbnail_url when present', () => {
const asset = makeAsset({
mime_type: 'image/png',
thumbnail_url: '/thumb.png',
preview_url: '/preview.png'
})
expect(renditionFor(asset, 'grid')).toBe('http://host/api/thumb.png')
})
it('falls back to preview_url when no thumbnail_url', () => {
const asset = makeAsset({
mime_type: 'image/png',
preview_url: '/preview.png'
})
expect(renditionFor(asset, 'grid')).toBe('http://host/api/preview.png')
})
it('falls back to canonical URL when renderable and no rendition exists', () => {
const asset = makeAsset({ mime_type: 'image/png' })
expect(renditionFor(asset, 'grid')).toBe(
'/api/view?filename=image.png&type=output'
)
})
it('returns null when not renderable and no rendition exists (EXR case)', () => {
const asset = makeAsset({ name: 'render.exr', mime_type: 'image/aces' })
expect(renditionFor(asset, 'grid')).toBeNull()
})
it('uses rendition for EXR when backend provides a transcoded preview', () => {
const asset = makeAsset({
name: 'render.exr',
mime_type: 'image/aces',
thumbnail_url: '/render.avif',
preview_url: '/render.avif'
})
expect(renditionFor(asset, 'grid')).toBe('http://host/api/render.avif')
})
it('returns null for opaque types with no preview (latent, safetensors)', () => {
const asset = makeAsset({
name: 'weights.safetensors',
mime_type: 'application/octet-stream'
})
expect(renditionFor(asset, 'grid')).toBeNull()
})
})
describe('lightbox surface', () => {
it('skips thumbnail_url and prefers preview_url', () => {
const asset = makeAsset({
mime_type: 'image/png',
thumbnail_url: '/thumb.png',
preview_url: '/full.png'
})
expect(renditionFor(asset, 'lightbox')).toBe('http://host/api/full.png')
})
it('falls back to canonical URL when renderable and no preview', () => {
const asset = makeAsset({ mime_type: 'image/png' })
expect(renditionFor(asset, 'lightbox')).toBe(
'/api/view?filename=image.png&type=output'
)
})
it('returns the AVIF rendition for an EXR', () => {
const asset = makeAsset({
name: 'render.exr',
mime_type: 'image/aces',
preview_url: '/render.avif'
})
expect(renditionFor(asset, 'lightbox')).toBe(
'http://host/api/render.avif'
)
})
it('returns null when not renderable and no preview exists', () => {
const asset = makeAsset({ name: 'render.exr', mime_type: 'image/aces' })
expect(renditionFor(asset, 'lightbox')).toBeNull()
})
})
describe('newTab surface', () => {
it('mirrors lightbox behaviour', () => {
const asset = makeAsset({
name: 'render.exr',
mime_type: 'image/aces',
preview_url: '/render.avif'
})
expect(renditionFor(asset, 'newTab')).toBe('http://host/api/render.avif')
})
})
describe('download surface', () => {
it('always returns the canonical URL, even when a preview exists', () => {
const asset = makeAsset({
name: 'render.exr',
mime_type: 'image/aces',
thumbnail_url: '/render.avif',
preview_url: '/render.avif'
})
expect(renditionFor(asset, 'download')).toBe(
'/api/view?filename=render.exr&type=output'
)
})
it('returns the canonical URL for browser-native assets too', () => {
const asset = makeAsset({
mime_type: 'image/png',
thumbnail_url: '/thumb.png'
})
expect(renditionFor(asset, 'download')).toBe(
'/api/view?filename=image.png&type=output'
)
})
})
describe('URL normalization', () => {
it('passes server-relative rendition URLs through api.apiURL', () => {
const asset = makeAsset({
mime_type: 'image/aces',
thumbnail_url: '/assets/abc-123/content'
})
expect(renditionFor(asset, 'grid')).toBe(
'http://host/api/assets/abc-123/content'
)
})
it('does not double-prefix URLs that already start with /api', () => {
const asset = makeAsset({
mime_type: 'image/png',
thumbnail_url: '/api/view?filename=already.png'
})
expect(renditionFor(asset, 'grid')).toBe(
'http://host/api/view?filename=already.png'
)
})
it('passes absolute http(s) URLs through untouched', () => {
const asset = makeAsset({
mime_type: 'image/png',
thumbnail_url: 'https://cdn.example.com/thumb.png'
})
expect(renditionFor(asset, 'grid')).toBe(
'https://cdn.example.com/thumb.png'
)
})
it('passes blob and data URLs through untouched', () => {
const blobAsset = makeAsset({
mime_type: 'image/png',
thumbnail_url: 'blob:http://host/abc-123'
})
expect(renditionFor(blobAsset, 'grid')).toBe('blob:http://host/abc-123')
const dataAsset = makeAsset({
mime_type: 'image/png',
thumbnail_url: 'data:image/png;base64,iVBORw0KG'
})
expect(renditionFor(dataAsset, 'grid')).toBe(
'data:image/png;base64,iVBORw0KG'
)
})
})
})

View File

@@ -0,0 +1,107 @@
import { api } from '@/scripts/api'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetUrl } from './assetUrlUtil'
type RenditionSurface = 'grid' | 'lightbox' | 'newTab' | 'download'
// Rendition URLs from the assets API are typically root-relative (e.g.
// `/assets/{id}/content`). Browser-absolute (http/https), blob, and data
// URLs are returned by some adapters (LoadImage widget, cloud previews)
// and must pass through untouched. Anything else goes via `api.apiURL()`
// so it gets the base prefix and `/api` route the server expects.
function normalizeRenditionUrl(url: string | null | undefined): string | null {
if (!url) return null
if (/^(https?:|blob:|data:)/i.test(url)) return url
if (url.startsWith('/')) return api.apiURL(url)
return url
}
// Image MIME types that every supported browser can render via `<img>`.
// Format policy lives here, not in callers. Keep this list narrow and
// authoritative — if a MIME isn't on it, the asset gets icon-fallback
// treatment regardless of file extension.
const RENDERABLE_IMAGE_MIME_TYPES = new Set<string>([
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/svg+xml',
'image/x-icon',
'image/vnd.microsoft.icon'
])
const RENDERABLE_VIDEO_MIME_TYPES = new Set<string>([
'video/mp4',
'video/webm',
'video/ogg'
])
const RENDERABLE_AUDIO_MIME_TYPES = new Set<string>([
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/wave',
'audio/x-wav',
'audio/ogg',
'audio/webm',
'audio/flac',
'audio/x-flac'
])
export function canRenderNatively(
mimeType: string | null | undefined
): boolean {
if (!mimeType) return false
const normalized = mimeType.toLowerCase().split(';')[0].trim()
return (
RENDERABLE_IMAGE_MIME_TYPES.has(normalized) ||
RENDERABLE_VIDEO_MIME_TYPES.has(normalized) ||
RENDERABLE_AUDIO_MIME_TYPES.has(normalized)
)
}
interface AssetRenditionFields {
mime_type?: string | null
preview_url?: string
thumbnail_url?: string
}
// Resolves which URL the UI should use for a given display surface, applying
// these rules (which align with the planned /assets API contract):
//
// grid / hover / sidebar: thumbnail_url ?? preview_url ?? (renderable ? canonical : null)
// lightbox / new tab: preview_url ?? (renderable ? canonical : null)
// download / open / copy: canonical (the original asset, never a transcoded substitute)
//
// Returning null means "no usable URL for this surface" — callers render an
// icon placeholder. The asset itself is never special-cased by extension;
// renderability is decided purely from `mime_type` via canRenderNatively().
export function renditionFor(
asset: AssetItem,
surface: RenditionSurface
): string | null {
const fields: AssetRenditionFields = asset
const canonical = getAssetUrl(asset)
const renderable = canRenderNatively(fields.mime_type)
switch (surface) {
case 'grid':
return (
normalizeRenditionUrl(fields.thumbnail_url) ||
normalizeRenditionUrl(fields.preview_url) ||
(renderable ? canonical : null)
)
case 'lightbox':
case 'newTab':
return (
normalizeRenditionUrl(fields.preview_url) ||
(renderable ? canonical : null)
)
case 'download':
return canonical
}
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { iconForMediaType } from './mediaIconUtil'
import { iconForMediaType, iconForMimeType } from './mediaIconUtil'
describe('iconForMediaType', () => {
it('maps text and misc fallbacks correctly', () => {
@@ -15,3 +15,52 @@ describe('iconForMediaType', () => {
expect(iconForMediaType('3D')).toBe('icon-[lucide--box]')
})
})
describe('iconForMimeType', () => {
it('maps image MIME types (including non-renderable variants) to file-image', () => {
expect(iconForMimeType('image/png')).toBe('icon-[lucide--file-image]')
expect(iconForMimeType('image/aces')).toBe('icon-[lucide--file-image]')
expect(iconForMimeType('image/x-exr')).toBe('icon-[lucide--file-image]')
expect(iconForMimeType('image/tiff')).toBe('icon-[lucide--file-image]')
})
it('maps video MIME types to file-video', () => {
expect(iconForMimeType('video/mp4')).toBe('icon-[lucide--file-video]')
expect(iconForMimeType('video/webm')).toBe('icon-[lucide--file-video]')
})
it('maps audio MIME types to file-audio', () => {
expect(iconForMimeType('audio/mpeg')).toBe('icon-[lucide--file-audio]')
expect(iconForMimeType('audio/wav')).toBe('icon-[lucide--file-audio]')
})
it('maps text MIME types to file-text', () => {
expect(iconForMimeType('text/plain')).toBe('icon-[lucide--file-text]')
expect(iconForMimeType('text/markdown')).toBe('icon-[lucide--file-text]')
})
it('maps model MIME types to the 3D box icon', () => {
expect(iconForMimeType('model/gltf-binary')).toBe('icon-[lucide--box]')
expect(iconForMimeType('model/obj')).toBe('icon-[lucide--box]')
expect(iconForMimeType('model/vnd.usdz+zip')).toBe('icon-[lucide--box]')
})
it('returns the generic file icon for unknown or missing MIME types', () => {
expect(iconForMimeType('application/x-safetensors')).toBe(
'icon-[lucide--file]'
)
expect(iconForMimeType('application/json')).toBe('icon-[lucide--file]')
expect(iconForMimeType('application/octet-stream')).toBe(
'icon-[lucide--file]'
)
expect(iconForMimeType('')).toBe('icon-[lucide--file]')
expect(iconForMimeType(null)).toBe('icon-[lucide--file]')
expect(iconForMimeType(undefined)).toBe('icon-[lucide--file]')
})
it('handles parameter suffixes and case', () => {
expect(iconForMimeType('image/PNG; charset=utf-8')).toBe(
'icon-[lucide--file-image]'
)
})
})

View File

@@ -16,3 +16,43 @@ export function iconForMediaType(mediaType: MediaKind): string {
return 'icon-[lucide--image]'
}
}
const FILE_IMAGE_ICON = 'icon-[lucide--file-image]'
const FILE_VIDEO_ICON = 'icon-[lucide--file-video]'
const FILE_AUDIO_ICON = 'icon-[lucide--file-audio]'
const FILE_TEXT_ICON = 'icon-[lucide--file-text]'
const FILE_GENERIC_ICON = 'icon-[lucide--file]'
const BOX_ICON = 'icon-[lucide--box]'
const THREE_D_MIME_TYPES = new Set<string>([
'model/gltf-binary',
'model/gltf+json',
'model/obj',
'model/vnd.usdz+zip'
])
// MIME-type → icon resolver. Generalises `iconForMediaType` for callers that
// have an authoritative MIME type instead of a media-family enum. Used by
// asset cards and previews to render a deliberate file-type icon when the
// browser cannot decode the asset for in-place display (EXR, RAW, latents,
// .safetensors, etc.) rather than the misleading broken-image state.
export function iconForMimeType(mimeType: string | null | undefined): string {
if (!mimeType) return FILE_GENERIC_ICON
const normalized = mimeType.toLowerCase().split(';')[0].trim()
const family = normalized.split('/')[0]
if (THREE_D_MIME_TYPES.has(normalized) || family === 'model') return BOX_ICON
switch (family) {
case 'image':
return FILE_IMAGE_ICON
case 'video':
return FILE_VIDEO_ICON
case 'audio':
return FILE_AUDIO_ICON
case 'text':
return FILE_TEXT_ICON
default:
return FILE_GENERIC_ICON
}
}