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:
Jin Yi
2025-10-12 06:39:04 +09:00
committed by GitHub
parent bb83b0107c
commit 9c0b3c4f7d
27 changed files with 1655 additions and 62 deletions

View File

@@ -0,0 +1,29 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<div
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i
class="icon-[lucide--box] text-3xl text-zinc-600 dark-theme:text-zinc-200"
/>
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
$t('3D Model')
}}</span>
</div>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,50 @@
<template>
<IconGroup>
<IconButton size="sm" @click="handleDelete">
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton size="sm" @click="handleDownload">
<i class="icon-[lucide--download] size-4" />
</IconButton>
<MoreButton
size="sm"
@menu-opened="emit('menuStateChanged', true)"
@menu-closed="emit('menuStateChanged', false)"
>
<template #default="{ close }">
<MediaAssetMoreMenu :close="close" />
</template>
</MoreButton>
</IconGroup>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
const emit = defineEmits<{
menuStateChanged: [isOpen: boolean]
}>()
const { asset } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
}
}
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset(asset.value.id)
}
}
</script>

View File

@@ -0,0 +1,4 @@
<template>
<div class="h-[1px] bg-neutral-200 dark-theme:bg-neutral-700"></div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,318 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
const meta: Meta<typeof MediaAssetCard> = {
title: 'AssetLibrary/MediaAssetCard',
component: MediaAssetCard,
argTypes: {
context: {
control: 'select',
options: ['input', 'output']
},
loading: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
// Public sample media URLs
const SAMPLE_MEDIA = {
image1: 'https://i.imgur.com/OB0y6MR.jpg',
image2: 'https://i.imgur.com/CzXTtJV.jpg',
image3: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
video:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
videoThumbnail:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg',
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
}
const sampleAsset: AssetMeta = {
id: 'asset-1',
name: 'sample-image.png',
kind: 'image',
duration: 3345,
size: 2048576,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image1,
dimensions: {
width: 1920,
height: 1080
},
tags: []
}
export const ImageAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'output', outputCount: 3 },
asset: sampleAsset,
loading: false
}
}
export const VideoAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-2',
name: 'Big_Buck_Bunny.mp4',
kind: 'video',
size: 10485760,
duration: 13425,
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
src: SAMPLE_MEDIA.video, // Actual video file
dimensions: {
width: 1280,
height: 720
}
}
}
}
export const Model3DAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-3',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
src: '',
dimensions: undefined,
duration: 18023
}
}
}
export const AudioAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-3',
name: 'SoundHelix-Song.mp3',
kind: 'audio',
size: 5242880,
src: SAMPLE_MEDIA.audio,
dimensions: undefined,
duration: 23180
}
}
}
export const LoadingState: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: sampleAsset,
loading: true
}
}
export const LongFileName: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png'
}
}
}
export const SelectedState: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'output', outputCount: 2 },
asset: sampleAsset,
selected: true
}
}
export const WebMVideo: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
id: 'asset-webm',
name: 'animated-clip.webm',
kind: 'video',
size: 3145728,
created_at: Date.now().toString(),
preview_url: SAMPLE_MEDIA.image1, // Poster image
src: 'https://www.w3schools.com/html/movie.mp4', // Actual video
duration: 620,
dimensions: {
width: 640,
height: 360
},
tags: []
}
}
}
export const GifAnimation: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
id: 'asset-gif',
name: 'animation.gif',
kind: 'image',
size: 1572864,
duration: 1345,
created_at: Date.now().toString(),
src: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
dimensions: {
width: 480,
height: 270
},
tags: []
}
}
}
export const GridLayout: Story = {
render: () => ({
components: { MediaAssetCard },
setup() {
const assets: AssetMeta[] = [
{
id: 'grid-1',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image1,
dimensions: { width: 1920, height: 1080 },
tags: []
},
{
id: 'grid-2',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image2,
dimensions: { width: 1920, height: 1080 },
tags: []
},
{
id: 'grid-3',
name: 'video-file.mp4',
kind: 'video',
size: 10485760,
duration: 13425,
created_at: Date.now().toString(),
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
src: SAMPLE_MEDIA.video, // Actual video
dimensions: { width: 1280, height: 720 },
tags: []
},
{
id: 'grid-4',
name: 'audio-file.mp3',
kind: 'audio',
size: 5242880,
duration: 180,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.audio,
tags: []
},
{
id: 'grid-5',
name: 'animation.gif',
kind: 'image',
size: 3145728,
duration: 1345,
created_at: Date.now().toString(),
src: 'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
dimensions: { width: 480, height: 360 },
tags: []
},
{
id: 'grid-6',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
src: '',
dimensions: undefined,
duration: 18023,
created_at: Date.now().toString(),
tags: []
},
{
id: 'grid-7',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image3,
dimensions: { width: 1920, height: 1080 },
tags: []
}
]
return { assets }
},
template: `
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; padding: 16px;">
<MediaAssetCard
v-for="asset in assets"
:key="asset.id"
:context="{ type: Math.random() > 0.5 ? 'input' : 'output', outputCount: Math.floor(Math.random() * 5) }"
:asset="asset"
/>
</div>
`
})
}

View 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>

View File

@@ -0,0 +1,158 @@
<template>
<div class="flex flex-col">
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Inspect asset"
@click="handleInspect"
>
<template #icon>
<i class="icon-[lucide--zoom-in] size-4" />
</template>
</IconTextButton>
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Add to current workflow"
@click="handleAddToWorkflow"
>
<template #icon>
<i class="icon-[comfy--node] size-4" />
</template>
</IconTextButton>
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Download"
@click="handleDownload"
>
<template #icon>
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
<MediaAssetButtonDivider />
<IconTextButton
v-if="showWorkflowOptions"
type="transparent"
class="dark-theme:text-white"
label="Open as workflow in new tab"
@click="handleOpenWorkflow"
>
<template #icon>
<i class="icon-[comfy--workflow] size-4" />
</template>
</IconTextButton>
<IconTextButton
v-if="showWorkflowOptions"
type="transparent"
class="dark-theme:text-white"
label="Export workflow"
@click="handleExportWorkflow"
>
<template #icon>
<i class="icon-[lucide--file-output] size-4" />
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Copy job ID"
@click="handleCopyJobId"
>
<template #icon>
<i class="icon-[lucide--copy] size-4" />
</template>
</IconTextButton>
<MediaAssetButtonDivider />
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Delete"
@click="handleDelete"
>
<template #icon>
<i class="icon-[lucide--trash-2] size-4" />
</template>
</IconTextButton>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
const { close } = defineProps<{
close: () => void
}>()
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const showWorkflowOptions = computed(() => {
return context.value.type
})
const handleInspect = () => {
if (asset.value) {
actions.viewAsset(asset.value.id)
}
close()
}
const handleAddToWorkflow = () => {
if (asset.value) {
actions.addWorkflow(asset.value.id)
}
close()
}
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset(asset.value.id)
}
close()
}
const handleOpenWorkflow = () => {
if (asset.value) {
actions.openWorkflow(asset.value.id)
}
close()
}
const handleExportWorkflow = () => {
if (asset.value) {
actions.exportWorkflow(asset.value.id)
}
close()
}
const handleCopyJobId = () => {
if (asset.value) {
actions.copyAssetUrl(asset.value.id)
}
close()
}
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
}
close()
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -0,0 +1,28 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<div
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i
class="icon-[lucide--music] text-3xl text-zinc-600 dark-theme:text-zinc-200"
/>
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
$t('Audio')
}}</span>
</div>
<audio
controls
class="absolute bottom-0 left-0 w-full p-2"
:src="asset.src"
@click.stop
/>
</div>
</template>
<script setup lang="ts">
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center text-xs text-zinc-400">
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<LazyImage
v-if="asset.src"
:src="asset.src"
:alt="asset.name"
:container-class="'aspect-square'"
:image-class="'w-full h-full object-cover'"
/>
<div
v-else
class="flex h-full w-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i class="pi pi-image text-3xl text-gray-400" />
</div>
</div>
</template>
<script setup lang="ts">
import LazyImage from '@/components/common/LazyImage.vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center text-xs text-zinc-400">
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div
class="relative h-full w-full overflow-hidden rounded bg-black"
@mouseenter="showControls = true"
@mouseleave="showControls = false"
>
<video
ref="videoRef"
:controls="showControls"
preload="none"
:poster="asset.preview_url"
class="relative h-full w-full object-contain"
@click.stop
@play="onVideoPlay"
@pause="onVideoPause"
>
<source :src="asset.src || ''" />
</video>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const emit = defineEmits<{
play: [assetId: string]
videoPlayingStateChanged: [isPlaying: boolean]
videoControlsChanged: [showControls: boolean]
}>()
const videoRef = ref<HTMLVideoElement>()
const showControls = ref(true)
watch(showControls, (controlsVisible) => {
emit('videoControlsChanged', controlsVisible)
})
onMounted(() => {
emit('videoControlsChanged', showControls.value)
})
const onVideoPlay = () => {
showControls.value = true
emit('videoPlayingStateChanged', true)
}
const onVideoPause = () => {
emit('videoPlayingStateChanged', false)
}
</script>

View File

@@ -0,0 +1,62 @@
/* eslint-disable no-console */
import type { AssetMeta } from '../schemas/mediaAssetSchema'
export function useMediaAssetActions() {
const selectAsset = (asset: AssetMeta) => {
console.log('Asset selected:', asset)
}
const viewAsset = (assetId: string) => {
console.log('Viewing asset:', assetId)
}
const downloadAsset = (assetId: string) => {
console.log('Downloading asset:', assetId)
}
const deleteAsset = (assetId: string) => {
console.log('Deleting asset:', assetId)
}
const playAsset = (assetId: string) => {
console.log('Playing asset:', assetId)
}
const copyAssetUrl = (assetId: string) => {
console.log('Copy asset URL:', assetId)
}
const copyJobId = (jobId: string) => {
console.log('Copy job ID:', jobId)
}
const addWorkflow = (assetId: string) => {
console.log('Adding asset to workflow:', assetId)
}
const openWorkflow = (assetId: string) => {
console.log('Opening workflow for asset:', assetId)
}
const exportWorkflow = (assetId: string) => {
console.log('Exporting workflow for asset:', assetId)
}
const openMoreOutputs = (assetId: string) => {
console.log('Opening more outputs for asset:', assetId)
}
return {
selectAsset,
viewAsset,
downloadAsset,
deleteAsset,
playAsset,
copyAssetUrl,
copyJobId,
addWorkflow,
openWorkflow,
exportWorkflow,
openMoreOutputs
}
}

View File

@@ -0,0 +1,46 @@
import type { InjectionKey, Ref } from 'vue'
import { z } from 'zod'
import { assetItemSchema } from './assetSchema'
const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D'])
export type MediaKind = z.infer<typeof zMediaKindSchema>
const zDimensionsSchema = z.object({
width: z.number().positive(),
height: z.number().positive()
})
// Extend the base asset schema with media-specific fields
const zMediaAssetDisplayItemSchema = assetItemSchema.extend({
// New required fields
kind: zMediaKindSchema,
src: z.string().url(),
// New optional fields
duration: z.number().nonnegative().optional(),
dimensions: zDimensionsSchema.optional(),
jobId: z.string().optional(),
isMulti: z.boolean().optional()
})
// Asset context schema
const zAssetContextSchema = z.object({
type: z.enum(['input', 'output']),
outputCount: z.number().positive().optional() // Only for output context
})
// Export the inferred types
export type AssetMeta = z.infer<typeof zMediaAssetDisplayItemSchema>
export type AssetContext = z.infer<typeof zAssetContextSchema>
// Injection key for MediaAsset provide/inject pattern
interface MediaAssetProviderValue {
asset: Ref<AssetMeta | undefined>
context: Ref<AssetContext>
isVideoPlaying: Ref<boolean>
showVideoControls: Ref<boolean>
}
export const MediaAssetKey: InjectionKey<MediaAssetProviderValue> =
Symbol('mediaAsset')