mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 03:01:54 +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 { defineComponent } from 'vue'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
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 { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||||
|
|
||||||
@@ -72,4 +72,21 @@ describe('AssetsSidebarListView', () => {
|
|||||||
|
|
||||||
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
|
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="
|
:aria-label="
|
||||||
t('assetBrowser.ariaLabel.assetCard', {
|
t('assetBrowser.ariaLabel.assetCard', {
|
||||||
name: item.asset.name,
|
name: item.asset.name,
|
||||||
type: getMediaTypeFromFilename(item.asset.name)
|
type: getAssetMediaType(item.asset)
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
:class="
|
:class="
|
||||||
@@ -45,9 +45,8 @@
|
|||||||
"
|
"
|
||||||
:preview-url="item.asset.preview_url"
|
:preview-url="item.asset.preview_url"
|
||||||
:preview-alt="item.asset.name"
|
:preview-alt="item.asset.name"
|
||||||
:icon-name="
|
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||||
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
|
:is-video-preview="isVideoAsset(item.asset)"
|
||||||
"
|
|
||||||
:primary-text="getAssetPrimaryText(item.asset)"
|
:primary-text="getAssetPrimaryText(item.asset)"
|
||||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||||
:stack-count="getStackCount(item.asset)"
|
:stack-count="getStackCount(item.asset)"
|
||||||
@@ -135,6 +134,14 @@ function getAssetPrimaryText(asset: AssetItem): string {
|
|||||||
return truncateFilename(asset.name)
|
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 {
|
function getAssetSecondaryText(asset: AssetItem): string {
|
||||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||||
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const GeneratedVideo: Story = {
|
|||||||
args: {
|
args: {
|
||||||
previewUrl: VIDEO_PREVIEW,
|
previewUrl: VIDEO_PREVIEW,
|
||||||
previewAlt: 'clip-01.mp4',
|
previewAlt: 'clip-01.mp4',
|
||||||
|
isVideoPreview: true,
|
||||||
primaryText: 'clip-01.mp4',
|
primaryText: 'clip-01.mp4',
|
||||||
secondaryText: '2m 12s'
|
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-class="iconClass"
|
||||||
:icon-aria-label="iconAriaLabel"
|
:icon-aria-label="iconAriaLabel"
|
||||||
>
|
>
|
||||||
<img
|
<div v-if="previewUrl" class="relative size-full">
|
||||||
v-if="previewUrl"
|
<template v-if="isVideoPreview">
|
||||||
:src="previewUrl"
|
<video
|
||||||
:alt="previewAlt"
|
:src="previewUrl"
|
||||||
class="size-full object-cover"
|
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">
|
<div v-else class="flex size-full items-center justify-center">
|
||||||
<i
|
<i
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -119,6 +131,8 @@ import { useProgressBarBackground } from '@/composables/useProgressBarBackground
|
|||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import VideoPlayOverlay from './VideoPlayOverlay.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'stack-toggle': []
|
'stack-toggle': []
|
||||||
}>()
|
}>()
|
||||||
@@ -130,6 +144,7 @@ const {
|
|||||||
iconAriaLabel,
|
iconAriaLabel,
|
||||||
iconClass,
|
iconClass,
|
||||||
iconWrapperClass,
|
iconWrapperClass,
|
||||||
|
isVideoPreview = false,
|
||||||
primaryText,
|
primaryText,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
stackCount,
|
stackCount,
|
||||||
@@ -144,6 +159,7 @@ const {
|
|||||||
iconAriaLabel?: string
|
iconAriaLabel?: string
|
||||||
iconClass?: string
|
iconClass?: string
|
||||||
iconWrapperClass?: string
|
iconWrapperClass?: string
|
||||||
|
isVideoPreview?: boolean
|
||||||
primaryText?: string
|
primaryText?: string
|
||||||
secondaryText?: string
|
secondaryText?: string
|
||||||
stackCount?: number
|
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"
|
@mouseleave="isHovered = false"
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
|
ref="videoElement"
|
||||||
:controls="shouldShowControls"
|
:controls="shouldShowControls"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsinline
|
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"
|
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"
|
@play="onVideoPlay"
|
||||||
@pause="onVideoPause"
|
@pause="onVideoPause"
|
||||||
@ended="onVideoEnded"
|
|
||||||
>
|
>
|
||||||
<source :src="asset.src || ''" />
|
<source
|
||||||
|
v-if="asset.src"
|
||||||
|
:src="asset.src"
|
||||||
|
:type="asset.mime_type ?? undefined"
|
||||||
|
/>
|
||||||
</video>
|
</video>
|
||||||
|
<VideoPlayOverlay :visible="!isPlaying" size="md" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -27,6 +31,8 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
|
|
||||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||||
|
|
||||||
|
import VideoPlayOverlay from './VideoPlayOverlay.vue'
|
||||||
|
|
||||||
const { asset } = defineProps<{
|
const { asset } = defineProps<{
|
||||||
asset: AssetMeta
|
asset: AssetMeta
|
||||||
}>()
|
}>()
|
||||||
@@ -36,11 +42,12 @@ const emit = defineEmits<{
|
|||||||
videoControlsChanged: [showControls: boolean]
|
videoControlsChanged: [showControls: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||||
const isHovered = ref(false)
|
const isHovered = ref(false)
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
|
|
||||||
// Always show controls when not playing, hide/show based on hover when playing
|
// Show native controls only while actively playing and hovered.
|
||||||
const shouldShowControls = computed(() => !isPlaying.value || isHovered.value)
|
const shouldShowControls = computed(() => isPlaying.value && isHovered.value)
|
||||||
|
|
||||||
watch(shouldShowControls, (controlsVisible) => {
|
watch(shouldShowControls, (controlsVisible) => {
|
||||||
emit('videoControlsChanged', controlsVisible)
|
emit('videoControlsChanged', controlsVisible)
|
||||||
@@ -60,8 +67,17 @@ const onVideoPause = () => {
|
|||||||
emit('videoPlayingStateChanged', false)
|
emit('videoPlayingStateChanged', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVideoEnded = () => {
|
const onVideoClick = async () => {
|
||||||
isPlaying.value = false
|
if (shouldShowControls.value) return
|
||||||
emit('videoPlayingStateChanged', false)
|
|
||||||
|
const video = videoElement.value
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
if (video.paused || video.ended) {
|
||||||
|
await video.play().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
video.pause()
|
||||||
}
|
}
|
||||||
</script>
|
</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