From 25880aa0241b53cca1f45d7641f051baa75353cb Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sat, 21 Feb 2026 00:59:36 -0800 Subject: [PATCH] fix: update asset video previews for card and list (#8908) ## Summary Render generated video previews in list items using a real video element (instead of an image element (this caused errors before)) and include a custom play button with dimming per [the designs](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3928-39270&m=dev). ## Changes - **What**: - List item preview path now renders a `video` element when `isVideoPreview` is true. - Video list preview uses `preload="metadata"`, `muted`, `playsinline`, and `pointer-events-none` so row click behavior stays unchanged. - Kept the custom overlay/play affordance and increased overlay dimming from `bg-black/10` to `bg-black/15`. - Updated tests for `AssetsListItem`, `MediaVideoTop`, and `AssetsSidebarListView`. ## Review Focus - Confirm list item click behavior still opens/selects asset (no inline playback interaction). - Confirm video list previews now show actual video frame path instead of broken image fallback. ## Limitation Backend does not currently provide a dedicated poster/thumbnail image for video outputs in the job preview payload. In the frontend today, we can either show a video icon placeholder, or load/render the full video itself to obtain a preview frame. ## Screenshots (if applicable) image image --- .../tabs/AssetsSidebarListView.test.ts | 19 +++- .../sidebar/tabs/AssetsSidebarListView.vue | 15 ++- .../components/AssetsListItem.stories.ts | 1 + .../assets/components/AssetsListItem.test.ts | 38 +++++++ .../assets/components/AssetsListItem.vue | 28 +++-- .../assets/components/MediaVideoTop.test.ts | 102 ++++++++++++++++++ .../assets/components/MediaVideoTop.vue | 34 ++++-- .../assets/components/VideoPlayOverlay.vue | 31 ++++++ 8 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 src/platform/assets/components/AssetsListItem.test.ts create mode 100644 src/platform/assets/components/MediaVideoTop.test.ts create mode 100644 src/platform/assets/components/VideoPlayOverlay.vue diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.test.ts b/src/components/sidebar/tabs/AssetsSidebarListView.test.ts index be6ead7e1..a99e23b62 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.test.ts +++ b/src/components/sidebar/tabs/AssetsSidebarListView.test.ts @@ -2,8 +2,8 @@ import { mount } from '@vue/test-utils' import { defineComponent } from 'vue' import { describe, expect, it, vi } from 'vitest' -import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import AssetsSidebarListView from './AssetsSidebarListView.vue' @@ -72,4 +72,21 @@ describe('AssetsSidebarListView', () => { expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader') }) + + it('marks mp4 assets as video previews', () => { + const videoAsset = { + ...buildAsset('video-asset', 'clip.mp4'), + preview_url: '/api/view/clip.mp4', + user_metadata: {} + } satisfies AssetItem + + const wrapper = mountListView([buildOutputItem(videoAsset)]) + + const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' }) + const assetListItem = listItems.at(-1) + + expect(assetListItem).toBeDefined() + expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4') + expect(assetListItem?.props('isVideoPreview')).toBe(true) + }) }) diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.vue b/src/components/sidebar/tabs/AssetsSidebarListView.vue index b1fc52ac5..0f06ff7ca 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.vue +++ b/src/components/sidebar/tabs/AssetsSidebarListView.vue @@ -34,7 +34,7 @@ :aria-label=" t('assetBrowser.ariaLabel.assetCard', { name: item.asset.name, - type: getMediaTypeFromFilename(item.asset.name) + type: getAssetMediaType(item.asset) }) " :class=" @@ -45,9 +45,8 @@ " :preview-url="item.asset.preview_url" :preview-alt="item.asset.name" - :icon-name=" - iconForMediaType(getMediaTypeFromFilename(item.asset.name)) - " + :icon-name="iconForMediaType(getAssetMediaType(item.asset))" + :is-video-preview="isVideoAsset(item.asset)" :primary-text="getAssetPrimaryText(item.asset)" :secondary-text="getAssetSecondaryText(item.asset)" :stack-count="getStackCount(item.asset)" @@ -135,6 +134,14 @@ function getAssetPrimaryText(asset: AssetItem): string { return truncateFilename(asset.name) } +function getAssetMediaType(asset: AssetItem) { + return getMediaTypeFromFilename(asset.name) +} + +function isVideoAsset(asset: AssetItem): boolean { + return getAssetMediaType(asset) === 'video' +} + function getAssetSecondaryText(asset: AssetItem): string { const metadata = getOutputAssetMetadata(asset.user_metadata) if (typeof metadata?.executionTimeInSeconds === 'number') { diff --git a/src/platform/assets/components/AssetsListItem.stories.ts b/src/platform/assets/components/AssetsListItem.stories.ts index ebf66fcd7..91cd7bc63 100644 --- a/src/platform/assets/components/AssetsListItem.stories.ts +++ b/src/platform/assets/components/AssetsListItem.stories.ts @@ -82,6 +82,7 @@ export const GeneratedVideo: Story = { args: { previewUrl: VIDEO_PREVIEW, previewAlt: 'clip-01.mp4', + isVideoPreview: true, primaryText: 'clip-01.mp4', secondaryText: '2m 12s' } diff --git a/src/platform/assets/components/AssetsListItem.test.ts b/src/platform/assets/components/AssetsListItem.test.ts new file mode 100644 index 000000000..aab7edc98 --- /dev/null +++ b/src/platform/assets/components/AssetsListItem.test.ts @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import AssetsListItem from './AssetsListItem.vue' + +describe('AssetsListItem', () => { + it('renders video element with play overlay for video previews', () => { + const wrapper = mount(AssetsListItem, { + props: { + previewUrl: 'https://example.com/preview.mp4', + previewAlt: 'clip.mp4', + isVideoPreview: true + } + }) + + const video = wrapper.find('video') + expect(video.exists()).toBe(true) + expect(video.attributes('src')).toBe('https://example.com/preview.mp4') + expect(video.attributes('preload')).toBe('metadata') + expect(wrapper.find('img').exists()).toBe(false) + expect(wrapper.find('.bg-black\\/15').exists()).toBe(true) + expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(true) + }) + + it('does not show play overlay for non-video previews', () => { + const wrapper = mount(AssetsListItem, { + props: { + previewUrl: 'https://example.com/preview.jpg', + previewAlt: 'image.png', + isVideoPreview: false + } + }) + + expect(wrapper.find('img').exists()).toBe(true) + expect(wrapper.find('video').exists()).toBe(false) + expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(false) + }) +}) diff --git a/src/platform/assets/components/AssetsListItem.vue b/src/platform/assets/components/AssetsListItem.vue index d20317147..581be8e60 100644 --- a/src/platform/assets/components/AssetsListItem.vue +++ b/src/platform/assets/components/AssetsListItem.vue @@ -35,12 +35,24 @@ :icon-class="iconClass" :icon-aria-label="iconAriaLabel" > - +
+ + +