mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 02:02:08 +00:00
Add MediaAssetCard presentation components (#5878)
## Summary
Implements a comprehensive media asset card component system for the
Asset Manager sidebar, enabling display and interaction with various
media types (images, videos, audio, and 3D models).
## Changes
### New Components
- **MediaAssetCard**: Main card component for displaying media assets
- **Media type-specific components**: Specialized display logic for each
media type
- MediaImageTop/Bottom
- MediaVideoTop/Bottom
- MediaAudioTop/Bottom
- Media3DTop/Bottom
- **MediaAssetActions**: Top-left action buttons (delete, download, more
options)
- **MediaAssetMoreMenu**: Dropdown menu for additional actions
- **SquareChip**: Chip component for displaying duration and file format
with dark/light variants
- **MediaAssetButtonDivider**: Visual separator for button groups
### Features
- **Video playback**: Autoplay with native video controls
- Dynamic duration chip positioning based on control visibility
- Hides overlays when video is playing
- **Audio playback**: Audio icon with HTML5 audio element
- Duration chip with consistent positioning
- **3D model support**: Icon display for 3D assets
- **Selection state**: Proper hover and selected state handling with CSS
priority fixes
### Architecture Improvements
- **Domain-Driven Design structure**: Organized under
`src/platform/mediaAsset/` following DDD principles
- **Provide/Inject pattern**: Eliminates props drilling with
MediaAssetKey InjectionKey
- **Composable pattern**: `useMediaAssetActions` manages all action
handlers
- **Type safety**: Comprehensive TypeScript types for media assets and
actions
### UI/UX Enhancements
- **CardTop component**: Added custom class props for slot positioning
- **SquareChip component**: Backdrop blur effects with variant system
- **Lazy loading**: Image optimization with LazyImage component
- **Responsive states**: Loading, selected, and hover states
### Utilities
- **formatDuration**: Converts milliseconds to human-readable format
(45s, 1m 23s, 1h 2m)
## Testing
- Comprehensive Storybook stories for all media types
- Grid layout examples
- Loading and selected state demonstrations
## File Structure
```
src/platform/assets/
├── components/
│ ├── MediaAssetCard.vue
│ ├── MediaAssetCard.stories.ts
│ ├── MediaAssetActions.vue
│ ├── MediaAssetMoreMenu.vue
│ ├── MediaAssetButtonDivider.vue
│ ├── MediaImageTop.vue
│ ├── MediaImageBottom.vue
│ ├── MediaVideoTop.vue
│ ├── MediaVideoBottom.vue
│ ├── MediaAudioTop.vue
│ ├── MediaAudioBottom.vue
│ ├── Media3DTop.vue
│ └── Media3DBottom.vue
├── composables/
│ └── useMediaAssetActions.ts
└── schemas/
└── mediaAssetSchema.ts
```
## Screenshots
[media_asset_record.webm](https://github.com/user-attachments/assets/d13b5cc0-a262-4850-bb81-ca1daa0dd969)
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
233
src/platform/assets/components/MediaAssetCard.vue
Normal file
233
src/platform/assets/components/MediaAssetCard.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<CardContainer
|
||||
ref="cardContainerRef"
|
||||
role="button"
|
||||
:aria-label="
|
||||
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
|
||||
"
|
||||
:tabindex="loading ? -1 : 0"
|
||||
size="mini"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:class="containerClasses"
|
||||
@click="handleCardClick"
|
||||
@keydown.enter="handleCardClick"
|
||||
@keydown.space.prevent="handleCardClick"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop
|
||||
ratio="square"
|
||||
:bottom-left-class="durationChipClasses"
|
||||
:bottom-right-class="durationChipClasses"
|
||||
>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div
|
||||
class="h-full w-full animate-pulse rounded-lg bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<component
|
||||
:is="getTopComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
@view="actions.viewAsset(asset!.id)"
|
||||
@download="actions.downloadAsset(asset!.id)"
|
||||
@play="actions.playAsset(asset!.id)"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Actions overlay (top-left) - show on hover or when menu is open, but not when video is playing -->
|
||||
<template v-if="showActionsOverlay" #top-left>
|
||||
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
|
||||
</template>
|
||||
|
||||
<!-- Zoom button (top-right) - show on hover, but not when video is playing -->
|
||||
<template v-if="showZoomOverlay" #top-right>
|
||||
<IconButton size="sm" @click="actions.viewAsset(asset!.id)">
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<!-- Duration/Format chips (bottom-left) - hide when video is playing -->
|
||||
<template v-if="showDurationChips" #bottom-left>
|
||||
<SquareChip variant="light" :label="formattedDuration" />
|
||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||
</template>
|
||||
|
||||
<!-- Output count (bottom-right) - hide when video is playing -->
|
||||
<template v-if="showOutputCount" #bottom-right>
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:label="context?.outputCount?.toString() ?? '0'"
|
||||
@click="actions.openMoreOutputs(asset?.id || '')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div class="flex flex-col items-center justify-between gap-1">
|
||||
<div
|
||||
class="h-4 w-2/3 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
<div
|
||||
class="h-3 w-1/2 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<component
|
||||
:is="getBottomComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
/>
|
||||
</template>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import { formatDuration } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type {
|
||||
AssetContext,
|
||||
AssetMeta,
|
||||
MediaKind
|
||||
} from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetActions from './MediaAssetActions.vue'
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
|
||||
},
|
||||
bottom: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
|
||||
}
|
||||
}
|
||||
|
||||
function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
}
|
||||
|
||||
function getBottomComponent(kind: MediaKind) {
|
||||
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
|
||||
}
|
||||
|
||||
const { context, asset, loading, selected } = defineProps<{
|
||||
context: AssetContext
|
||||
asset?: AssetMeta
|
||||
loading?: boolean
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const cardContainerRef = ref<HTMLElement>()
|
||||
|
||||
const isVideoPlaying = ref(false)
|
||||
const isMenuOpen = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
|
||||
const isHovered = useElementHover(cardContainerRef)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
provide(MediaAssetKey, {
|
||||
asset: toRef(() => asset),
|
||||
context: toRef(() => context),
|
||||
isVideoPlaying,
|
||||
showVideoControls
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
return cn(
|
||||
'gap-1',
|
||||
selected
|
||||
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
|
||||
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
|
||||
)
|
||||
})
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
if (!asset?.duration) return ''
|
||||
return formatDuration(asset.duration)
|
||||
})
|
||||
|
||||
const fileFormat = computed(() => {
|
||||
if (!asset?.name) return ''
|
||||
const parts = asset.name.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
|
||||
})
|
||||
|
||||
const durationChipClasses = computed(() => {
|
||||
if (asset?.kind === 'audio') {
|
||||
return '-translate-y-11'
|
||||
}
|
||||
if (asset?.kind === 'video' && showVideoControls.value) {
|
||||
return '-translate-y-16'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const showHoverActions = computed(() => {
|
||||
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
|
||||
})
|
||||
|
||||
const showZoomButton = computed(() => {
|
||||
return asset?.kind === 'image' || asset?.kind === '3D'
|
||||
})
|
||||
|
||||
const showActionsOverlay = computed(() => {
|
||||
return showHoverActions.value && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const showZoomOverlay = computed(() => {
|
||||
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const showDurationChips = computed(() => {
|
||||
return !loading && asset?.duration && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const showOutputCount = computed(() => {
|
||||
return !loading && context?.outputCount && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (asset) {
|
||||
actions.selectAsset(asset)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user