mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-04 04:30:04 +00:00
## 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>
135 lines
3.2 KiB
Vue
135 lines
3.2 KiB
Vue
<template>
|
|
<div
|
|
ref="containerRef"
|
|
class="relative flex h-full w-full items-center justify-center overflow-hidden"
|
|
:class="containerClass"
|
|
>
|
|
<Skeleton
|
|
v-if="!isImageLoaded"
|
|
width="100%"
|
|
height="100%"
|
|
class="absolute inset-0"
|
|
/>
|
|
<img
|
|
v-if="cachedSrc"
|
|
ref="imageRef"
|
|
:src="cachedSrc"
|
|
:alt="alt"
|
|
draggable="false"
|
|
:class="imageClass"
|
|
:style="imageStyle"
|
|
@load="onImageLoad"
|
|
@error="onImageError"
|
|
/>
|
|
<div
|
|
v-if="hasError"
|
|
class="absolute inset-0 flex items-center justify-center bg-surface-50 text-muted dark-theme:bg-surface-800"
|
|
>
|
|
<img
|
|
src="/assets/images/default-template.png"
|
|
:alt="alt"
|
|
draggable="false"
|
|
:class="imageClass"
|
|
:style="imageStyle"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import Skeleton from 'primevue/skeleton'
|
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
|
|
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
|
import { useMediaCache } from '@/services/mediaCacheService'
|
|
import type { ClassValue } from '@/utils/tailwindUtil'
|
|
|
|
const {
|
|
src,
|
|
alt = '',
|
|
containerClass = '',
|
|
imageClass = '',
|
|
imageStyle,
|
|
rootMargin = '300px'
|
|
} = defineProps<{
|
|
src: string
|
|
alt?: string
|
|
containerClass?: ClassValue
|
|
imageClass?: ClassValue
|
|
imageStyle?: Record<string, any>
|
|
rootMargin?: string
|
|
}>()
|
|
|
|
const containerRef = ref<HTMLElement | null>(null)
|
|
const imageRef = ref<HTMLImageElement | null>(null)
|
|
const isIntersecting = ref(false)
|
|
const isImageLoaded = ref(false)
|
|
const hasError = ref(false)
|
|
const cachedSrc = ref<string | undefined>(undefined)
|
|
|
|
const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
|
|
|
|
// Use intersection observer to detect when the image container comes into view
|
|
useIntersectionObserver(
|
|
containerRef,
|
|
(entries) => {
|
|
const entry = entries[0]
|
|
isIntersecting.value = entry?.isIntersecting ?? false
|
|
},
|
|
{
|
|
rootMargin,
|
|
threshold: 0.1
|
|
}
|
|
)
|
|
|
|
// Only start loading the image when it's in view
|
|
const shouldLoad = computed(() => isIntersecting.value)
|
|
|
|
watch(
|
|
shouldLoad,
|
|
async (shouldLoadVal) => {
|
|
if (shouldLoadVal && src && !cachedSrc.value && !hasError.value) {
|
|
try {
|
|
const cachedMedia = await getCachedMedia(src)
|
|
if (cachedMedia.error) {
|
|
hasError.value = true
|
|
} else if (cachedMedia.objectUrl) {
|
|
const acquiredUrl = acquireUrl(src)
|
|
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
|
|
} else {
|
|
cachedSrc.value = src
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load cached media:', error)
|
|
cachedSrc.value = src
|
|
}
|
|
} else if (!shouldLoadVal) {
|
|
if (cachedSrc.value?.startsWith('blob:')) {
|
|
releaseUrl(src)
|
|
}
|
|
// Hide image when out of view
|
|
isImageLoaded.value = false
|
|
cachedSrc.value = undefined
|
|
hasError.value = false
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
const onImageLoad = () => {
|
|
isImageLoaded.value = true
|
|
hasError.value = false
|
|
}
|
|
|
|
const onImageError = () => {
|
|
hasError.value = true
|
|
isImageLoaded.value = false
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
if (cachedSrc.value?.startsWith('blob:')) {
|
|
releaseUrl(src)
|
|
}
|
|
})
|
|
</script>
|