mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
2 Commits
test/sideb
...
glary/asse
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293fde098d | ||
|
|
6f3bacf483 |
@@ -3097,6 +3097,7 @@
|
||||
}
|
||||
},
|
||||
"mediaAsset": {
|
||||
"previewNotAvailable": "Preview not available",
|
||||
"deleteAssetTitle": "Delete this asset?",
|
||||
"deleteAssetDescription": "This asset will be permanently removed.",
|
||||
"deleteSelectedTitle": "Delete selected assets?",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
243
src/platform/assets/utils/assetRenditions.test.ts
Normal file
243
src/platform/assets/utils/assetRenditions.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
107
src/platform/assets/utils/assetRenditions.ts
Normal file
107
src/platform/assets/utils/assetRenditions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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]'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user