Media Assets Management Sidebar Tab Implementation (#6112)

## 📋 Overview
Implemented a new Media Assets sidebar tab in ComfyUI for managing
user-uploaded input files and generated output files. This feature
supports both local and cloud environments and is currently enabled only
in development mode.

## 🎯 Key Features

### 1. Media Assets Sidebar Tab
- **Imported** / **Generated** files separated by tabs
- Visual display with file preview cards
- Gallery view support (navigable with arrow keys)

### 2. Environment-Specific Implementation
- **`useInternalMediaAssets`**: For local environment
  - Fetches file list via `/files` API
  - Retrieves generation task execution time via `/history` API
  - Processes history data using the same logic as QueueSidebarTab
- **`useCloudMediaAssets`**: For cloud environment
  - File retrieval through assetService
  - History data processing using TaskItemImpl
- Auto-truncation of long filenames over 20 characters (e.g.,
`very_long_filename_here.png` → `very_long_...here.png`)

### 3. Execution Time Display
- Shows task execution time on generated image cards (e.g., "2.3s")
- Calculated from History API's `execution_start` and
`execution_success` messages
- Displayed at MediaAssetCard's duration chip location

### 4. Gallery Feature
- Full-screen gallery mode on image click
- Navigate between images with keyboard arrows
- Exit gallery with ESC key
- Reuses ResultGallery component from QueueSidebarTab

### 5. Development Mode Only
- Excluded from production builds using `import.meta.env.DEV` condition
- Feature in development, scheduled for official release after
stabilization

## 🛠️ Technical Changes

### New Files Added
- `src/components/sidebar/tabs/AssetsSidebarTab.vue` - Main sidebar tab
component
- `src/composables/sidebarTabs/useAssetsSidebarTab.ts` - Sidebar tab
definition
- `src/composables/useInternalMediaAssets.ts` - Local environment
implementation
- `src/composables/useCloudMediaAssets.ts` - Cloud environment
implementation
- `packages/design-system/src/icons/image-ai-edit.svg` - Icon addition

### Modified Files
- `src/stores/workspace/sidebarTabStore.ts` - Added dev mode only tab
display logic
- `src/platform/assets/components/MediaAssetCard.vue` - Added execution
time display, zoom event
- `src/platform/assets/components/MediaImageTop.vue` - Added image
dimension detection
- `packages/shared-frontend-utils/src/formatUtil.ts` - Added media type
determination utility functions
- `src/locales/en/main.json` - Added translation keys


[media_asset_OSS_cloud.webm](https://github.com/user-attachments/assets/a6ee3b49-19ed-4735-baad-c2ac2da868ef)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Jin Yi
2025-10-29 12:39:16 +09:00
committed by GitHub
parent 5f3b8fb8c8
commit 06ba106f59
60 changed files with 1797 additions and 229 deletions

View File

@@ -1,11 +1,6 @@
<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>
<MediaTitle :file-name="fileName" />
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
@@ -18,6 +13,7 @@ import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<div class="relative size-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"
class="flex size-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"

View File

@@ -1,6 +1,6 @@
<template>
<IconGroup>
<IconButton size="sm" @click="handleDelete">
<IconButton v-if="showDeleteButton" size="sm" @click="handleDelete">
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton size="sm" @click="handleDownload">
@@ -12,18 +12,23 @@
@menu-closed="emit('menuStateChanged', false)"
>
<template #default="{ close }">
<MediaAssetMoreMenu :close="close" />
<MediaAssetMoreMenu
:close="close"
@inspect="emit('inspect')"
@asset-deleted="emit('asset-deleted')"
/>
</template>
</MoreButton>
</IconGroup>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { computed, 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 { isCloud } from '@/platform/distribution/types'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
@@ -31,20 +36,35 @@ import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
const emit = defineEmits<{
menuStateChanged: [isOpen: boolean]
inspect: []
'asset-deleted': []
}>()
const { asset } = inject(MediaAssetKey)!
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
const assetType = computed(() => {
return context?.value?.type || asset.value?.tags?.[0] || 'output'
})
const showDeleteButton = computed(() => {
return (
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
)
})
const handleDelete = async () => {
if (!asset.value) return
const success = await actions.confirmDelete(asset.value)
if (success) {
emit('asset-deleted')
}
}
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset(asset.value.id)
actions.downloadAsset()
}
}
</script>

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetItem } from '../schemas/assetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
const meta: Meta<typeof MediaAssetCard> = {
@@ -28,10 +28,6 @@ const meta: Meta<typeof MediaAssetCard> = {
})
],
argTypes: {
context: {
control: 'select',
options: ['input', 'output']
},
loading: {
control: 'boolean'
}
@@ -53,19 +49,20 @@ const SAMPLE_MEDIA = {
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
}
const sampleAsset: AssetMeta = {
const sampleAsset: AssetItem = {
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: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image1,
tags: ['input'],
user_metadata: {
duration: 3345,
dimensions: {
width: 1920,
height: 1080
}
}
}
export const ImageAsset: Story = {
@@ -75,7 +72,6 @@ export const ImageAsset: Story = {
})
],
args: {
context: { type: 'output', outputCount: 3 },
asset: sampleAsset,
loading: false
}
@@ -88,19 +84,18 @@ export const VideoAsset: Story = {
})
],
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
preview_url: SAMPLE_MEDIA.videoThumbnail,
user_metadata: {
duration: 13425,
dimensions: {
width: 1280,
height: 720
}
}
}
}
@@ -113,16 +108,15 @@ export const Model3DAsset: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-3',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
src: '',
dimensions: undefined,
duration: 18023
preview_url: '',
user_metadata: {
duration: 18023
}
}
}
}
@@ -134,16 +128,15 @@ export const AudioAsset: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-3',
id: 'asset-4',
name: 'SoundHelix-Song.mp3',
kind: 'audio',
size: 5242880,
src: SAMPLE_MEDIA.audio,
dimensions: undefined,
duration: 23180
preview_url: SAMPLE_MEDIA.audio,
user_metadata: {
duration: 23180
}
}
}
}
@@ -155,7 +148,6 @@ export const LoadingState: Story = {
})
],
args: {
context: { type: 'input' },
asset: sampleAsset,
loading: true
}
@@ -168,7 +160,6 @@ export const LongFileName: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png'
@@ -183,7 +174,6 @@ export const SelectedState: Story = {
})
],
args: {
context: { type: 'output', outputCount: 2 },
asset: sampleAsset,
selected: true
}
@@ -196,21 +186,20 @@ export const WebMVideo: Story = {
})
],
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: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image1,
tags: ['input'],
user_metadata: {
duration: 620,
dimensions: {
width: 640,
height: 360
}
}
}
}
}
@@ -222,20 +211,20 @@ export const GifAnimation: Story = {
})
],
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: []
created_at: new Date().toISOString(),
preview_url: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
tags: ['input'],
user_metadata: {
duration: 1345,
dimensions: {
width: 480,
height: 270
}
}
}
}
}
@@ -244,83 +233,89 @@ export const GridLayout: Story = {
render: () => ({
components: { MediaAssetCard },
setup() {
const assets: AssetMeta[] = [
const assets: AssetItem[] = [
{
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: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image1,
tags: ['input'],
user_metadata: {
duration: 4500,
dimensions: { width: 1920, height: 1080 }
}
},
{
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: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image2,
tags: ['input'],
user_metadata: {
duration: 4500,
dimensions: { width: 1920, height: 1080 }
}
},
{
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: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.videoThumbnail,
tags: ['input'],
user_metadata: {
duration: 13425,
dimensions: { width: 1280, height: 720 }
}
},
{
id: 'grid-4',
name: 'audio-file.mp3',
kind: 'audio',
size: 5242880,
duration: 180,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.audio,
tags: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.audio,
tags: ['input'],
user_metadata: {
duration: 180
}
},
{
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: []
created_at: new Date().toISOString(),
preview_url:
'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
tags: ['input'],
user_metadata: {
duration: 1345,
dimensions: { width: 480, height: 360 }
}
},
{
id: 'grid-6',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
src: '',
dimensions: undefined,
duration: 18023,
created_at: Date.now().toString(),
tags: []
preview_url: '',
created_at: new Date().toISOString(),
tags: ['input'],
user_metadata: {
duration: 18023
}
},
{
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: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image3,
tags: ['input'],
user_metadata: {
duration: 4500,
dimensions: { width: 1920, height: 1080 }
}
}
]
return { assets }
@@ -330,7 +325,6 @@ export const GridLayout: Story = {
<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

@@ -3,7 +3,12 @@
ref="cardContainerRef"
role="button"
:aria-label="
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.name,
type: fileKind
})
: $t('assetBrowser.ariaLabel.loadingAsset')
"
:tabindex="loading ? -1 : 0"
size="mini"
@@ -28,16 +33,17 @@
</template>
<!-- Content based on asset type -->
<template v-else-if="asset">
<template v-else-if="asset && adaptedAsset">
<component
:is="getTopComponent(asset.kind)"
:asset="asset"
:context="context"
:is="getTopComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
@view="handleZoomClick"
@download="actions.downloadAsset(asset!.id)"
@play="actions.playAsset(asset!.id)"
@download="actions.downloadAsset()"
@play="actions.playAsset(asset.id)"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"
/>
</template>
@@ -45,6 +51,8 @@
<template v-if="showActionsOverlay" #top-left>
<MediaAssetActions
@menu-state-changed="isMenuOpen = $event"
@inspect="handleZoomClick"
@asset-deleted="handleAssetDelete"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
/>
@@ -63,13 +71,17 @@
</template>
<!-- Duration/Format chips (bottom-left) - show on hover even when playing -->
<template v-if="showDurationChips" #bottom-left>
<template v-if="showDurationChips || showFileFormatChip" #bottom-left>
<div
class="flex flex-wrap items-center gap-1"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<SquareChip variant="light" :label="formattedDuration" />
<SquareChip
v-if="formattedDuration"
variant="light"
:label="formattedDuration"
/>
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
</div>
</template>
@@ -79,8 +91,8 @@
<IconTextButton
type="secondary"
size="sm"
:label="context?.outputCount?.toString() ?? '0'"
@click.stop="actions.openMoreOutputs(asset?.id || '')"
:label="String(outputCount)"
@click.stop="handleOutputCountClick"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
@@ -107,11 +119,11 @@
</template>
<!-- Content based on asset type -->
<template v-else-if="asset">
<template v-else-if="asset && adaptedAsset">
<component
:is="getBottomComponent(asset.kind)"
:asset="asset"
:context="context"
:is="getBottomComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
/>
</template>
</CardBottom>
@@ -129,16 +141,13 @@ 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 { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type {
AssetContext,
AssetMeta,
MediaKind
} from '../schemas/mediaAssetSchema'
import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetActions from './MediaAssetActions.vue'
@@ -165,11 +174,18 @@ function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const { context, asset, loading, selected } = defineProps<{
context: AssetContext
asset?: AssetMeta
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
asset?: AssetItem
loading?: boolean
selected?: boolean
showOutputCount?: boolean
outputCount?: number
}>()
const emit = defineEmits<{
zoom: [asset: AssetItem]
'output-count-click': []
'asset-deleted': []
}>()
const cardContainerRef = ref<HTMLElement>()
@@ -179,14 +195,44 @@ const isMenuOpen = ref(false)
const showVideoControls = ref(false)
const isOverlayHovered = ref(false)
// Store actual image dimensions
const imageDimensions = ref<{ width: number; height: number } | undefined>()
const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
// Get asset type from tags
const assetType = computed(() => {
return getAssetType(asset?.tags)
})
// Determine file type from extension
const fileKind = computed((): MediaKind => {
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
})
// Adapt AssetItem to legacy AssetMeta format for existing components
const adaptedAsset = computed(() => {
if (!asset) return undefined
return {
id: asset.id,
name: asset.name,
kind: fileKind.value,
src: asset.preview_url || '',
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,
duration: asset.user_metadata?.duration
? Number(asset.user_metadata.duration)
: undefined,
dimensions: imageDimensions.value
}
})
provide(MediaAssetKey, {
asset: toRef(() => asset),
context: toRef(() => context),
asset: toRef(() => adaptedAsset.value),
context: toRef(() => ({ type: assetType.value })),
isVideoPlaying,
showVideoControls
})
@@ -201,8 +247,16 @@ const containerClasses = computed(() =>
)
const formattedDuration = computed(() => {
if (!asset?.duration) return ''
return formatDuration(asset.duration)
// Check for execution time first (from history API)
const executionTime = asset?.user_metadata?.executionTimeInSeconds
if (executionTime !== undefined && executionTime !== null) {
return `${Number(executionTime).toFixed(2)}s`
}
// Fall back to duration for media files
const duration = asset?.user_metadata?.duration
if (!duration) return ''
return formatDuration(Number(duration))
})
const fileFormat = computed(() => {
@@ -212,10 +266,10 @@ const fileFormat = computed(() => {
})
const durationChipClasses = computed(() => {
if (asset?.kind === 'audio') {
if (fileKind.value === 'audio') {
return '-translate-y-11'
}
if (asset?.kind === 'video' && showVideoControls.value) {
if (fileKind.value === 'video' && showVideoControls.value) {
return '-translate-y-16'
}
return ''
@@ -238,27 +292,29 @@ const showActionsOverlay = computed(
const showZoomOverlay = computed(
() =>
showHoverActions.value &&
asset?.kind !== '3D' &&
fileKind.value !== '3D' &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showDurationChips = computed(
() =>
!loading &&
asset?.duration &&
(asset?.user_metadata?.executionTimeInSeconds ||
asset?.user_metadata?.duration) &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showOutputCount = computed(
const showFileFormatChip = computed(
() =>
!loading &&
context?.outputCount &&
!!asset &&
!!fileFormat.value &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const handleCardClick = () => {
if (asset) {
actions.selectAsset(asset)
if (adaptedAsset.value) {
actions.selectAsset(adaptedAsset.value)
}
}
@@ -272,7 +328,19 @@ const handleOverlayMouseLeave = () => {
const handleZoomClick = () => {
if (asset) {
galleryStore.openSingle(asset)
emit('zoom', asset)
}
}
const handleImageLoaded = (width: number, height: number) => {
imageDimensions.value = { width, height }
}
const handleOutputCountClick = () => {
emit('output-count-click')
}
const handleAssetDelete = () => {
emit('asset-deleted')
}
</script>

View File

@@ -13,6 +13,7 @@
</IconTextButton>
<IconTextButton
v-if="showWorkflowOptions"
type="transparent"
class="dark-theme:text-white"
label="Add to current workflow"
@@ -34,7 +35,7 @@
</template>
</IconTextButton>
<MediaAssetButtonDivider />
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
<IconTextButton
v-if="showWorkflowOptions"
@@ -60,9 +61,10 @@
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
<MediaAssetButtonDivider v-if="showWorkflowOptions && showCopyJobId" />
<IconTextButton
v-if="showCopyJobId"
type="transparent"
class="dark-theme:text-white"
label="Copy job ID"
@@ -73,9 +75,10 @@
</template>
</IconTextButton>
<MediaAssetButtonDivider />
<MediaAssetButtonDivider v-if="showCopyJobId && showDeleteButton" />
<IconTextButton
v-if="showDeleteButton"
type="transparent"
class="dark-theme:text-white"
label="Delete"
@@ -92,9 +95,9 @@
import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { isCloud } from '@/platform/distribution/types'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
@@ -102,16 +105,36 @@ const { close } = defineProps<{
close: () => void
}>()
const emit = defineEmits<{
inspect: []
'asset-deleted': []
}>()
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
const showWorkflowOptions = computed(() => context.value.type)
const assetType = computed(() => {
return asset.value?.tags?.[0] || context.value?.type || 'output'
})
const showWorkflowOptions = computed(() => assetType.value === 'output')
// Only show Copy Job ID for output assets (not for imported/input assets)
const showCopyJobId = computed(() => {
return assetType.value !== 'input'
})
// Delete button should be shown for:
// - All output files (can be deleted via history)
// - Input files only in cloud environment
const showDeleteButton = computed(() => {
return (
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
)
})
const handleInspect = () => {
if (asset.value) {
galleryStore.openSingle(asset.value)
}
emit('inspect')
close()
}
@@ -124,7 +147,7 @@ const handleAddToWorkflow = () => {
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset(asset.value.id)
actions.downloadAsset()
}
close()
}
@@ -143,17 +166,21 @@ const handleExportWorkflow = () => {
close()
}
const handleCopyJobId = () => {
const handleCopyJobId = async () => {
if (asset.value) {
actions.copyAssetUrl(asset.value.id)
await actions.copyJobId()
}
close()
}
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
const handleDelete = async () => {
if (!asset.value) return
close() // Close the menu first
const success = await actions.confirmDelete(asset.value)
if (success) {
emit('asset-deleted')
}
close()
}
</script>

View File

@@ -1,11 +1,6 @@
<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>
<MediaTitle :file-name="fileName" />
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
@@ -18,6 +13,7 @@ import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<div class="relative size-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"
class="flex size-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"

View File

@@ -1,13 +1,10 @@
<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>
<MediaTitle :file-name="fileName" />
<div class="flex items-center text-xs text-zinc-400">
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
<span v-if="asset.dimensions"
>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span
>
</div>
</div>
</template>
@@ -18,6 +15,7 @@ import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,15 +1,16 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<LazyImage
v-if="asset.src"
<div
class="relative size-full overflow-hidden rounded bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<img
v-if="!error"
:src="asset.src"
:alt="asset.name"
:container-class="'aspect-square'"
:image-class="'w-full h-full object-cover'"
class="size-full object-contain"
/>
<div
v-else
class="flex h-full w-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex size-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i class="pi pi-image text-3xl text-smoke-400" />
</div>
@@ -17,11 +18,27 @@
</template>
<script setup lang="ts">
import LazyImage from '@/components/common/LazyImage.vue'
import { useImage, whenever } from '@vueuse/core'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const emit = defineEmits<{
'image-loaded': [width: number, height: number]
}>()
const { state, error, isReady } = useImage({
src: asset.src ?? '',
alt: asset.name
})
whenever(
() =>
isReady.value && state.value?.naturalWidth && state.value?.naturalHeight,
() =>
emit('image-loaded', state.value!.naturalWidth, state.value!.naturalHeight)
)
</script>

View File

@@ -0,0 +1,21 @@
<template>
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="fullName"
>
{{ displayName }}
</h3>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { truncateFilename } from '@/utils/formatUtil'
const props = defineProps<{
fileName: string
}>()
const fullName = computed(() => props.fileName)
const displayName = computed(() => truncateFilename(props.fileName))
</script>

View File

@@ -1,13 +1,8 @@
<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>
<MediaTitle :file-name="fileName" />
<div class="flex items-center text-xs text-zinc-400">
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
@@ -15,9 +10,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,6 +1,6 @@
<template>
<div
class="relative h-full w-full overflow-hidden rounded bg-black"
class="relative size-full overflow-hidden rounded bg-black"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
@@ -9,7 +9,7 @@
:controls="shouldShowControls"
preload="none"
:poster="asset.preview_url"
class="relative h-full w-full object-contain"
class="relative size-full object-contain"
@click.stop
@play="onVideoPlay"
@pause="onVideoPause"