mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 07:44:11 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
38
src/platform/assets/components/AssetsListItem.test.ts
Normal file
38
src/platform/assets/components/AssetsListItem.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
102
src/platform/assets/components/MediaVideoTop.test.ts
Normal file
102
src/platform/assets/components/MediaVideoTop.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
31
src/platform/assets/components/VideoPlayOverlay.vue
Normal file
31
src/platform/assets/components/VideoPlayOverlay.vue
Normal 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>
|
||||
Reference in New Issue
Block a user