From 9184f9bce4b2018b0ba981f05511bfad5a8be965 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sat, 21 Feb 2026 01:27:28 -0800 Subject: [PATCH] fix: support text and misc generated asset states (#8914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Align generated-asset state classification to a single shared source and implement the missing text/misc states in both card and list previews. ## Changes - **What**: - Extended `getMediaTypeFromFilename` in `packages/shared-frontend-utils` to return `text` and `other`, and changed unknown/no-extension fallback from `image` to `other`. - Added text extension handling (`txt`, `md`, `json`, `csv`, `yaml/yml`, `xml`, `log`) and kept existing media kinds. - Updated generated-assets UI to use shared media-type detection directly (removed the local generated-assets classifier). - Added text and misc card preview components: - `text` -> `icon-[lucide--text]` - `other` -> `icon-[lucide--check-check]` - Updated list-item preview behavior so only `image`/`video` use preview media URLs; `text`/`other` use icon fallback. - Widened media kind schema for asset display metadata to include `text` and `other`. - **Breaking**: No API breaking changes; internal media kind union widened for frontend asset display paths. - **Dependencies**: None. ## Review Focus - Verify generated text assets render paragraph/text icon state in card + list. - Verify unknown/misc assets consistently render double-check icon state in card + list. - Verify existing image/video/audio/3D behavior remains unchanged. ## Screenshots (if applicable) image image image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8914-fix-support-text-and-misc-generated-asset-states-3096d73d365081f28ca7c32f306e4b50) by [Unito](https://www.unito.io) --------- Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Co-authored-by: GitHub Action --- .../src/formatUtil.test.ts | 28 +++++++++---- .../shared-frontend-utils/src/formatUtil.ts | 39 +++++++++++++++---- .../tabs/AssetsSidebarListView.stories.ts | 26 +++++++++++++ .../tabs/AssetsSidebarListView.test.ts | 17 ++++++++ .../sidebar/tabs/AssetsSidebarListView.vue | 10 ++++- .../sidebar/tabs/AssetsSidebarTab.vue | 15 +++++-- .../components/MediaAssetCard.stories.ts | 34 ++++++++++++++++ .../assets/components/MediaAssetCard.vue | 18 ++++++--- .../assets/components/MediaOtherTop.vue | 9 +++++ .../assets/components/MediaTextTop.vue | 9 +++++ .../assets/schemas/mediaAssetSchema.ts | 9 ++++- .../assets/utils/mediaIconUtil.test.ts | 17 ++++++++ src/platform/assets/utils/mediaIconUtil.ts | 4 ++ 13 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 src/platform/assets/components/MediaOtherTop.vue create mode 100644 src/platform/assets/components/MediaTextTop.vue create mode 100644 src/platform/assets/utils/mediaIconUtil.test.ts diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index 9b793fe91..a10d1d790 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -56,7 +56,8 @@ describe('formatUtil', () => { { filename: 'image.jpeg', expected: 'image' }, { filename: 'animation.gif', expected: 'image' }, { filename: 'web.webp', expected: 'image' }, - { filename: 'bitmap.bmp', expected: 'image' } + { filename: 'bitmap.bmp', expected: 'image' }, + { filename: 'modern.avif', expected: 'image' } ] it.for(imageTestCases)( @@ -96,26 +97,37 @@ describe('formatUtil', () => { expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D') expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D') expect(getMediaTypeFromFilename('binary.glb')).toBe('3D') + expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D') + }) + }) + + describe('text files', () => { + it('should identify text file extensions correctly', () => { + expect(getMediaTypeFromFilename('notes.txt')).toBe('text') + expect(getMediaTypeFromFilename('readme.md')).toBe('text') + expect(getMediaTypeFromFilename('data.json')).toBe('text') + expect(getMediaTypeFromFilename('table.csv')).toBe('text') + expect(getMediaTypeFromFilename('config.yaml')).toBe('text') }) }) describe('edge cases', () => { it('should handle empty strings', () => { - expect(getMediaTypeFromFilename('')).toBe('image') + expect(getMediaTypeFromFilename('')).toBe('other') }) it('should handle files without extensions', () => { - expect(getMediaTypeFromFilename('README')).toBe('image') + expect(getMediaTypeFromFilename('README')).toBe('other') }) it('should handle unknown extensions', () => { - expect(getMediaTypeFromFilename('document.pdf')).toBe('image') - expect(getMediaTypeFromFilename('data.json')).toBe('image') + expect(getMediaTypeFromFilename('document.pdf')).toBe('other') + expect(getMediaTypeFromFilename('archive.bin')).toBe('other') }) it('should handle files with multiple dots', () => { expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image') - expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image') + expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other') }) it('should handle paths with directories', () => { @@ -124,8 +136,8 @@ describe('formatUtil', () => { }) it('should handle null and undefined gracefully', () => { - expect(getMediaTypeFromFilename(null)).toBe('image') - expect(getMediaTypeFromFilename(undefined)).toBe('image') + expect(getMediaTypeFromFilename(null)).toBe('other') + expect(getMediaTypeFromFilename(undefined)).toBe('other') }) it('should handle special characters in filenames', () => { diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index d01820fe6..f610a0a8e 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -494,19 +494,41 @@ export function formatDuration(milliseconds: number): string { return parts.join(' ') } -const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const +const IMAGE_EXTENSIONS = [ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'bmp', + 'avif', + 'tif', + 'tiff' +] as const const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const -const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const +const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const +const TEXT_EXTENSIONS = [ + 'txt', + 'md', + 'markdown', + 'json', + 'csv', + 'yaml', + 'yml', + 'xml', + 'log' +] as const -const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const -type MediaType = (typeof MEDIA_TYPES)[number] +const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const +export type MediaType = (typeof MEDIA_TYPES)[number] // Type guard helper for checking array membership type ImageExtension = (typeof IMAGE_EXTENSIONS)[number] type VideoExtension = (typeof VIDEO_EXTENSIONS)[number] type AudioExtension = (typeof AUDIO_EXTENSIONS)[number] type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number] +type TextExtension = (typeof TEXT_EXTENSIONS)[number] /** * Truncates a filename while preserving the extension @@ -543,20 +565,21 @@ export function truncateFilename( /** * Determines the media type from a filename's extension (singular form) * @param filename The filename to analyze - * @returns The media type: 'image', 'video', 'audio', or '3D' + * @returns The media type: 'image', 'video', 'audio', '3D', 'text', or 'other' */ export function getMediaTypeFromFilename( filename: string | null | undefined ): MediaType { - if (!filename) return 'image' + if (!filename) return 'other' const ext = filename.split('.').pop()?.toLowerCase() - if (!ext) return 'image' + if (!ext) return 'other' // Type-safe array includes check using type assertion if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image' if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video' if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio' if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D' + if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text' - return 'image' + return 'other' } diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts b/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts index 70dc82493..add90360a 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts +++ b/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts @@ -112,6 +112,22 @@ const sampleAssets: AssetItem[] = [ created_at: baseTimestamp, size: 134217728, tags: [] + }, + { + id: 'asset-text-1', + name: 'generation-notes.txt', + created_at: baseTimestamp, + preview_url: '/assets/images/default-template.png', + size: 2048, + tags: [] + }, + { + id: 'asset-other-1', + name: 'workflow-payload.bin', + created_at: baseTimestamp, + preview_url: '/assets/images/default-template.png', + size: 4096, + tags: [] } ] @@ -134,6 +150,16 @@ export const RunningAndGenerated: Story = { render: renderAssetsSidebarListView } +export const TextAndMiscGeneratedAssets: Story = { + args: { + assets: sampleAssets.filter((asset) => + ['.txt', '.bin'].some((suffix) => asset.name.endsWith(suffix)) + ), + jobs: [] + }, + render: renderAssetsSidebarListView +} + function renderAssetsSidebarListView(args: StoryArgs) { return { components: { AssetsSidebarListView }, diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.test.ts b/src/components/sidebar/tabs/AssetsSidebarListView.test.ts index a99e23b62..aa2d704f1 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.test.ts +++ b/src/components/sidebar/tabs/AssetsSidebarListView.test.ts @@ -89,4 +89,21 @@ describe('AssetsSidebarListView', () => { expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4') expect(assetListItem?.props('isVideoPreview')).toBe(true) }) + + it('uses icon fallback for text assets even when preview_url exists', () => { + const textAsset = { + ...buildAsset('text-asset', 'notes.txt'), + preview_url: '/api/view/notes.txt', + user_metadata: {} + } satisfies AssetItem + + const wrapper = mountListView([buildOutputItem(textAsset)]) + + const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' }) + const assetListItem = listItems.at(-1) + + expect(assetListItem).toBeDefined() + expect(assetListItem?.props('previewUrl')).toBe('') + expect(assetListItem?.props('isVideoPreview')).toBe(false) + }) }) diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.vue b/src/components/sidebar/tabs/AssetsSidebarListView.vue index 0f06ff7ca..e0192e2ae 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.vue +++ b/src/components/sidebar/tabs/AssetsSidebarListView.vue @@ -43,7 +43,7 @@ item.isChild && 'pl-6' ) " - :preview-url="item.asset.preview_url" + :preview-url="getAssetPreviewUrl(item.asset)" :preview-alt="item.asset.name" :icon-name="iconForMediaType(getAssetMediaType(item.asset))" :is-video-preview="isVideoAsset(item.asset)" @@ -142,6 +142,14 @@ function isVideoAsset(asset: AssetItem): boolean { return getAssetMediaType(asset) === 'video' } +function getAssetPreviewUrl(asset: AssetItem): string { + const mediaType = getAssetMediaType(asset) + if (mediaType === 'image' || mediaType === 'video') { + return asset.preview_url || '' + } + return '' +} + function getAssetSecondaryText(asset: AssetItem): string { const metadata = getOutputAssetMetadata(asset.user_metadata) if (typeof metadata?.executionTimeInSeconds === 'number') { diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index ccf7b9868..9cfbcdf93 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -204,13 +204,22 @@ import { } from '@vueuse/core' import Divider from 'primevue/divider' import { useToast } from 'primevue/usetoast' -import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { + computed, + defineAsyncComponent, + nextTick, + onMounted, + onUnmounted, + ref, + watch +} from 'vue' import { useI18n } from 'vue-i18n' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' // Lazy-loaded to avoid pulling THREE.js into the main bundle -const Load3dViewerContent = () => - import('@/components/load3d/Load3dViewerContent.vue') +const Load3dViewerContent = defineAsyncComponent( + () => import('@/components/load3d/Load3dViewerContent.vue') +) import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue' import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue' import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue' diff --git a/src/platform/assets/components/MediaAssetCard.stories.ts b/src/platform/assets/components/MediaAssetCard.stories.ts index d7ccc8ac0..d40bbc07f 100644 --- a/src/platform/assets/components/MediaAssetCard.stories.ts +++ b/src/platform/assets/components/MediaAssetCard.stories.ts @@ -141,6 +141,40 @@ export const AudioAsset: Story = { } } +export const TextAsset: Story = { + decorators: [ + () => ({ + template: '
' + }) + ], + args: { + asset: { + ...sampleAsset, + id: 'asset-5', + name: 'generation-notes.txt', + size: 2048, + preview_url: SAMPLE_MEDIA.image1 + } + } +} + +export const OtherAsset: Story = { + decorators: [ + () => ({ + template: '
' + }) + ], + args: { + asset: { + ...sampleAsset, + id: 'asset-6', + name: 'workflow-payload.bin', + size: 8192, + preview_url: SAMPLE_MEDIA.image1 + } + } +} + export const LoadingState: Story = { decorators: [ () => ({ diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index f6179f303..7d86fd2de 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -36,7 +36,7 @@ + const mediaComponents = { top: { video: defineAsyncComponent(() => import('./MediaVideoTop.vue')), audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')), image: defineAsyncComponent(() => import('./MediaImageTop.vue')), - '3D': defineAsyncComponent(() => import('./Media3DTop.vue')) + '3D': defineAsyncComponent(() => import('./Media3DTop.vue')), + text: defineAsyncComponent(() => import('./MediaTextTop.vue')), + other: defineAsyncComponent(() => import('./MediaOtherTop.vue')) } } -function getTopComponent(kind: MediaKind) { - return mediaComponents.top[kind] || mediaComponents.top.image +function getTopComponent(kind: PreviewKind) { + return mediaComponents.top[kind] || mediaComponents.top.other } const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{ @@ -206,7 +210,11 @@ const assetType = computed(() => { // Determine file type from extension const fileKind = computed((): MediaKind => { - return getMediaTypeFromFilename(asset?.name || '') as MediaKind + return getMediaTypeFromFilename(asset?.name || '') +}) + +const previewKind = computed((): PreviewKind => { + return getMediaTypeFromFilename(asset?.name || '') }) // Get filename without extension diff --git a/src/platform/assets/components/MediaOtherTop.vue b/src/platform/assets/components/MediaOtherTop.vue new file mode 100644 index 000000000..e976e9be7 --- /dev/null +++ b/src/platform/assets/components/MediaOtherTop.vue @@ -0,0 +1,9 @@ + diff --git a/src/platform/assets/components/MediaTextTop.vue b/src/platform/assets/components/MediaTextTop.vue new file mode 100644 index 000000000..048151e97 --- /dev/null +++ b/src/platform/assets/components/MediaTextTop.vue @@ -0,0 +1,9 @@ + diff --git a/src/platform/assets/schemas/mediaAssetSchema.ts b/src/platform/assets/schemas/mediaAssetSchema.ts index 818db6b4a..acd909e00 100644 --- a/src/platform/assets/schemas/mediaAssetSchema.ts +++ b/src/platform/assets/schemas/mediaAssetSchema.ts @@ -3,7 +3,14 @@ import { z } from 'zod' import { assetItemSchema } from './assetSchema' -const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D']) +const zMediaKindSchema = z.enum([ + 'video', + 'audio', + 'image', + '3D', + 'text', + 'other' +]) export type MediaKind = z.infer const zDimensionsSchema = z.object({ diff --git a/src/platform/assets/utils/mediaIconUtil.test.ts b/src/platform/assets/utils/mediaIconUtil.test.ts new file mode 100644 index 000000000..b7dfdc0fa --- /dev/null +++ b/src/platform/assets/utils/mediaIconUtil.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { iconForMediaType } from './mediaIconUtil' + +describe('iconForMediaType', () => { + it('maps text and misc fallbacks correctly', () => { + expect(iconForMediaType('text')).toBe('icon-[lucide--text]') + expect(iconForMediaType('other')).toBe('icon-[lucide--check-check]') + }) + + it('preserves existing mappings for core media types', () => { + expect(iconForMediaType('image')).toBe('icon-[lucide--image]') + expect(iconForMediaType('video')).toBe('icon-[lucide--video]') + expect(iconForMediaType('audio')).toBe('icon-[lucide--music]') + expect(iconForMediaType('3D')).toBe('icon-[lucide--box]') + }) +}) diff --git a/src/platform/assets/utils/mediaIconUtil.ts b/src/platform/assets/utils/mediaIconUtil.ts index d698fa610..587bc4a79 100644 --- a/src/platform/assets/utils/mediaIconUtil.ts +++ b/src/platform/assets/utils/mediaIconUtil.ts @@ -8,6 +8,10 @@ export function iconForMediaType(mediaType: MediaKind): string { return 'icon-[lucide--music]' case '3D': return 'icon-[lucide--box]' + case 'text': + return 'icon-[lucide--text]' + case 'other': + return 'icon-[lucide--check-check]' default: return 'icon-[lucide--image]' }