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)
<img width="427" height="499" alt="image" src="https://github.com/user-attachments/assets/3f974817-9d73-4fee-9fa5-2f1f68942c06" />
<img width="230" height="92" alt="image" src="https://github.com/user-attachments/assets/1fbfdd6a-72dd-47e2-96bf-8f7eb41c36f2" />
This commit is contained in:
Benjamin Lu
2026-02-21 00:59:36 -08:00
committed by GitHub
parent 40aa7c5974
commit 25880aa024
8 changed files with 248 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,12 +35,24 @@
:icon-class="iconClass"
:icon-aria-label="iconAriaLabel"
>
<img
v-if="previewUrl"
:src="previewUrl"
:alt="previewAlt"
class="size-full object-cover"
/>
<div v-if="previewUrl" class="relative size-full">
<template v-if="isVideoPreview">
<video
:src="previewUrl"
preload="metadata"
muted
playsinline
class="pointer-events-none size-full object-cover"
/>
<VideoPlayOverlay size="sm" />
</template>
<img
v-else
:src="previewUrl"
:alt="previewAlt"
class="size-full object-cover"
/>
</div>
<div v-else class="flex size-full items-center justify-center">
<i
aria-hidden="true"
@@ -119,6 +131,8 @@ import { useProgressBarBackground } from '@/composables/useProgressBarBackground
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import VideoPlayOverlay from './VideoPlayOverlay.vue'
const emit = defineEmits<{
'stack-toggle': []
}>()
@@ -130,6 +144,7 @@ const {
iconAriaLabel,
iconClass,
iconWrapperClass,
isVideoPreview = false,
primaryText,
secondaryText,
stackCount,
@@ -144,6 +159,7 @@ const {
iconAriaLabel?: string
iconClass?: string
iconWrapperClass?: string
isVideoPreview?: boolean
primaryText?: string
secondaryText?: string
stackCount?: number

View File

@@ -0,0 +1,102 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaVideoTop from './MediaVideoTop.vue'
function createVideoAsset(
src: string,
mimeType: AssetMeta['mime_type'] = 'video/mp4'
): AssetMeta {
return {
id: 'video-1',
name: 'clip.mp4',
asset_hash: null,
mime_type: mimeType,
tags: [],
kind: 'video',
src
}
}
describe('MediaVideoTop', () => {
it('renders playable video with darkened paused overlay and play icon', () => {
const wrapper = mount(MediaVideoTop, {
props: {
asset: createVideoAsset('https://example.com/thumb.jpg')
}
})
const video = wrapper.find('video')
const videoElement = video.element as HTMLVideoElement
expect(video.exists()).toBe(true)
expect(videoElement.controls).toBe(false)
expect(wrapper.find('source').attributes('src')).toBe(
'https://example.com/thumb.jpg'
)
expect(wrapper.find('source').attributes('type')).toBe('video/mp4')
expect(wrapper.find('.bg-black\\/15').exists()).toBe(true)
expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(true)
})
it('does not render source element when src is empty', () => {
const wrapper = mount(MediaVideoTop, {
props: {
asset: createVideoAsset('')
}
})
expect(wrapper.find('video').exists()).toBe(true)
expect(wrapper.find('source').exists()).toBe(false)
})
it('emits playback events and hides paused overlay while playing', async () => {
const wrapper = mount(MediaVideoTop, {
props: {
asset: createVideoAsset('https://example.com/thumb.jpg')
}
})
const video = wrapper.find('video')
const videoElement = video.element as HTMLVideoElement
expect(video.exists()).toBe(true)
await video.trigger('play')
expect(wrapper.emitted('videoPlayingStateChanged')?.at(-1)).toEqual([true])
expect(wrapper.find('.bg-black\\/15').exists()).toBe(false)
await wrapper.trigger('mouseenter')
expect(videoElement.controls).toBe(true)
await wrapper.trigger('mouseleave')
expect(videoElement.controls).toBe(false)
await video.trigger('pause')
expect(wrapper.emitted('videoPlayingStateChanged')?.at(-1)).toEqual([false])
expect(wrapper.find('.bg-black\\/15').exists()).toBe(true)
expect(videoElement.controls).toBe(false)
})
it('starts playback from click when controls are hidden', async () => {
const wrapper = mount(MediaVideoTop, {
props: {
asset: createVideoAsset('https://example.com/thumb.jpg')
}
})
const video = wrapper.find('video')
const videoElement = video.element as HTMLVideoElement
const playSpy = vi
.spyOn(videoElement, 'play')
.mockImplementation(() => Promise.resolve())
Object.defineProperty(videoElement, 'paused', {
value: true,
configurable: true
})
await video.trigger('click')
expect(playSpy).toHaveBeenCalledTimes(1)
})
})

View File

@@ -5,20 +5,24 @@
@mouseleave="isHovered = false"
>
<video
ref="videoElement"
:controls="shouldShowControls"
preload="metadata"
muted
loop
playsinline
:poster="asset.preview_url"
class="relative size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
@click.stop
@click.stop="onVideoClick"
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
>
<source :src="asset.src || ''" />
<source
v-if="asset.src"
:src="asset.src"
:type="asset.mime_type ?? undefined"
/>
</video>
<VideoPlayOverlay :visible="!isPlaying" size="md" />
</div>
</template>
@@ -27,6 +31,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import VideoPlayOverlay from './VideoPlayOverlay.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
@@ -36,11 +42,12 @@ const emit = defineEmits<{
videoControlsChanged: [showControls: boolean]
}>()
const videoElement = ref<HTMLVideoElement | null>(null)
const isHovered = ref(false)
const isPlaying = ref(false)
// Always show controls when not playing, hide/show based on hover when playing
const shouldShowControls = computed(() => !isPlaying.value || isHovered.value)
// Show native controls only while actively playing and hovered.
const shouldShowControls = computed(() => isPlaying.value && isHovered.value)
watch(shouldShowControls, (controlsVisible) => {
emit('videoControlsChanged', controlsVisible)
@@ -60,8 +67,17 @@ const onVideoPause = () => {
emit('videoPlayingStateChanged', false)
}
const onVideoEnded = () => {
isPlaying.value = false
emit('videoPlayingStateChanged', false)
const onVideoClick = async () => {
if (shouldShowControls.value) return
const video = videoElement.value
if (!video) return
if (video.paused || video.ended) {
await video.play().catch(() => {})
return
}
video.pause()
}
</script>

View File

@@ -0,0 +1,31 @@
<template>
<template v-if="visible">
<div :class="cn('pointer-events-none absolute inset-0', overlayClass)" />
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<i
aria-hidden="true"
:class="cn('icon-[lucide--play] text-white', iconSizeClass)"
/>
</div>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
visible = true,
size = 'md',
overlayClass = 'bg-black/15'
} = defineProps<{
visible?: boolean
size?: 'sm' | 'md'
overlayClass?: string
}>()
const iconSizeClass = computed(() => (size === 'sm' ? 'size-3' : 'size-6'))
</script>