feat: Add Media Assets sidebar tab for file management

- Implement new sidebar tab for managing imported/generated files
- Add separate composables for internal and cloud environments
- Display execution time from history API on generated outputs
- Support gallery view with keyboard navigation
- Auto-truncate long filenames in cloud environment
- Add utility functions for media type detection
- Enable feature only in development mode

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jin Yi
2025-10-18 00:05:54 +09:00
parent 9cd7d06a6d
commit 2398e26712
12 changed files with 718 additions and 157 deletions

View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 9.99996L11.9427 7.94263C11.6926 7.69267 11.3536 7.55225 11 7.55225C10.6464 7.55225 10.3074 7.69267 10.0573 7.94263L9 9M8 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.51377 12.671L4.77612 14.3921C4.67222 14.6346 4.32853 14.6346 4.22463 14.3921L3.48699 12.671C3.45664 12.6002 3.40022 12.5437 3.32942 12.5134L1.60825 11.7757C1.36581 11.6718 1.36581 11.3282 1.60825 11.2243L3.32942 10.4866C3.40022 10.4563 3.45664 10.3998 3.48699 10.329L4.22463 8.60787C4.32853 8.36544 4.67222 8.36544 4.77612 8.60787L5.51377 10.329C5.54411 10.3998 5.60053 10.4563 5.67134 10.4866L7.39251 11.2243C7.63494 11.3282 7.63494 11.6718 7.39251 11.7757L5.67134 12.5134C5.60053 12.5437 5.54411 12.6002 5.51377 12.671Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5 5H5.0001" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -474,3 +474,51 @@ export function formatDuration(milliseconds: number): string {
return parts.join(' ')
}
/**
* Determines the media type from a filename's extension
* @param filename The filename to analyze
* @returns The media type: 'images', 'videos', 'audios', '3D' for gallery compatibility
*/
export function getMediaTypeFromFilename(filename: string): string {
if (!filename) return 'images'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'images'
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']
const videoExts = ['mp4', 'webm', 'mov', 'avi']
const audioExts = ['mp3', 'wav', 'ogg', 'flac']
const threeDExts = ['obj', 'fbx', 'gltf', 'glb']
if (imageExts.includes(ext)) return 'images'
if (videoExts.includes(ext)) return 'videos'
if (audioExts.includes(ext)) return 'audios'
if (threeDExts.includes(ext)) return '3D'
return 'images'
}
/**
* Determines the media kind from a filename's extension
* @param filename The filename to analyze
* @returns The media kind: 'image', 'video', 'audio', or '3D'
*/
export function getMediaKindFromFilename(
filename: string
): 'image' | 'video' | 'audio' | '3D' {
if (!filename) return 'image'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']
const videoExts = ['mp4', 'webm', 'mov', 'avi']
const audioExts = ['mp3', 'wav', 'ogg', 'flac']
const threeDExts = ['obj', 'fbx', 'gltf', 'glb']
if (imageExts.includes(ext)) return 'image'
if (videoExts.includes(ext)) return 'video'
if (audioExts.includes(ext)) return 'audio'
if (threeDExts.includes(ext)) return '3D'
return 'image'
}

View File

@@ -0,0 +1,150 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.mediaAssets')">
<template #header>
<Tabs v-model:value="activeTab" class="w-full">
<TabList class="border-b border-neutral-300">
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
</TabList>
</Tabs>
</template>
<template #body>
<VirtualGrid
v-if="mediaAssets.length"
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem',
gap: '0.5rem'
}"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item"
:selected="selectedAsset?.id === item.id"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
/>
</template>
</VirtualGrid>
<div v-else-if="loading">
<ProgressSpinner
style="width: 50px; left: 50%; transform: translateX(-50%)"
/>
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
$t(
activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
</template>
</SidebarTabTemplate>
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</template>
<script setup lang="ts">
import ProgressSpinner from 'primevue/progressspinner'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import Tabs from 'primevue/tabs'
import { computed, onMounted, ref, watch } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useCloudMediaAssets } from '@/composables/useCloudMediaAssets'
import { useInternalMediaAssets } from '@/composables/useInternalMediaAssets'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { ResultItemImpl } from '@/stores/queueStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
const activeTab = ref<'input' | 'output'>('input')
const mediaAssets = ref<AssetItem[]>([])
const selectedAsset = ref<AssetItem | null>(null)
// Use appropriate implementation based on environment
const implementation = isCloud
? useCloudMediaAssets()
: useInternalMediaAssets()
const { loading, error, fetchMediaList } = implementation
const galleryActiveIndex = ref(-1)
const galleryItems = computed(() => {
// Convert AssetItems to ResultItemImpl format for gallery
return mediaAssets.value.map((asset) => {
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: getMediaTypeFromFilename(asset.name)
})
// Override the url getter to use asset.preview_url
Object.defineProperty(resultItem, 'url', {
get() {
return asset.preview_url || ''
},
configurable: true
})
return resultItem
})
})
// Add key property for VirtualGrid
const mediaAssetsWithKey = computed(() => {
return mediaAssets.value.map((asset) => ({
...asset,
key: asset.id
}))
})
const refreshAssets = async () => {
const files = await fetchMediaList(activeTab.value)
mediaAssets.value = files
if (error.value) {
console.error('Failed to refresh assets:', error.value)
}
}
watch(activeTab, () => {
void refreshAssets()
})
onMounted(() => {
void refreshAssets()
})
const handleAssetSelect = (asset: AssetItem) => {
// Toggle selection
if (selectedAsset.value?.id === asset.id) {
selectedAsset.value = null
} else {
selectedAsset.value = asset
}
}
const handleZoomClick = (asset: AssetItem) => {
// Find the index of the clicked asset
const index = mediaAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) {
galleryActiveIndex.value = index
}
}
</script>

View File

@@ -0,0 +1,16 @@
import { markRaw } from 'vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useAssetsSidebarTab = (): SidebarTabExtension => {
return {
id: 'assets',
icon: 'icon-[comfy--image-ai-edit]',
title: 'sideToolbar.assets',
tooltip: 'sideToolbar.assets',
label: 'sideToolbar.labels.assets',
component: markRaw(AssetsSidebarTab),
type: 'vue'
}
}

View File

@@ -0,0 +1,133 @@
import { ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { HistoryTaskItem } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { TaskItemImpl } from '@/stores/queueStore'
/**
* Composable for fetching media assets from cloud environment
* Includes execution time from history API
*/
export function useCloudMediaAssets() {
const loading = ref(false)
const error = ref<string | null>(null)
/**
* Fetch list of assets from cloud with execution time
* @param directory - 'input' or 'output'
* @returns Array of AssetItem with execution time in user_metadata
*/
const fetchMediaList = async (
directory: 'input' | 'output'
): Promise<AssetItem[]> => {
loading.value = true
error.value = null
try {
// For input directory, just return assets without history
if (directory === 'input') {
const assets = await assetService.getAssetsByTag(directory)
return assets
}
// For output directory, fetch history data and convert to AssetItem format
const historyResponse = await api.getHistory(200)
if (!historyResponse?.History) {
return []
}
// Convert history items to AssetItem format
const assetItems: AssetItem[] = []
historyResponse.History.forEach((historyItem: HistoryTaskItem) => {
// Create TaskItemImpl to use existing logic
const taskItem = new TaskItemImpl(
historyItem.taskType,
historyItem.prompt,
historyItem.status,
historyItem.outputs
)
// Only process completed tasks
if (taskItem.displayStatus === 'Completed' && taskItem.outputs) {
// Get execution time
const executionTimeInSeconds = taskItem.executionTimeInSeconds
// Process each output
taskItem.flatOutputs.forEach((output) => {
// Only include output type files (not temp previews)
if (output.type === 'output' && output.supportsPreview) {
// Truncate filename if longer than 15 characters
let displayName = output.filename
if (output.filename.length > 20) {
// Get file extension
const lastDotIndex = output.filename.lastIndexOf('.')
const nameWithoutExt =
lastDotIndex > -1
? output.filename.substring(0, lastDotIndex)
: output.filename
const extension =
lastDotIndex > -1
? output.filename.substring(lastDotIndex)
: ''
// If name without extension is still long, truncate it
if (nameWithoutExt.length > 10) {
displayName =
nameWithoutExt.substring(0, 10) +
'...' +
nameWithoutExt.substring(nameWithoutExt.length - 10) +
extension
}
}
assetItems.push({
id: `${taskItem.promptId}-${output.nodeId}-${output.filename}`,
name: displayName,
size: 0, // We don't have size info from history
created_at: taskItem.executionStartTimestamp
? new Date(taskItem.executionStartTimestamp).toISOString()
: new Date().toISOString(),
tags: ['output'],
preview_url: output.url,
user_metadata: {
originalFilename: output.filename, // Store original filename
promptId: taskItem.promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
...(executionTimeInSeconds && {
executionTimeInSeconds
}),
...(output.format && {
format: output.format
}),
...(taskItem.workflow && {
workflow: taskItem.workflow
})
}
})
}
})
}
})
return assetItems
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
console.error(`Error fetching ${directory} cloud assets:`, errorMessage)
error.value = errorMessage
return []
} finally {
loading.value = false
}
}
return {
loading,
error,
fetchMediaList
}
}

View File

@@ -0,0 +1,129 @@
import { ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { HistoryTaskItem } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { TaskItemImpl } from '@/stores/queueStore'
/**
* Composable for fetching media assets from local environment
* Uses the same logic as QueueSidebarTab for history processing
*/
export function useInternalMediaAssets() {
const loading = ref(false)
const error = ref<string | null>(null)
/**
* Fetch list of files from input or output directory with execution time
* @param directory - 'input' or 'output'
* @returns Array of AssetItem with execution time in user_metadata
*/
const fetchMediaList = async (
directory: 'input' | 'output'
): Promise<AssetItem[]> => {
loading.value = true
error.value = null
try {
// For input directory, fetch files without history
if (directory === 'input') {
const response = await fetch(api.internalURL(`/files/${directory}`), {
headers: {
'Comfy-User': api.user
}
})
if (!response.ok) {
throw new Error(`Failed to fetch ${directory} files`)
}
const filenames: string[] = await response.json()
return filenames.map((name, index) => ({
id: `${directory}-${index}-${name}`,
name,
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
preview_url: api.apiURL(
`/view?filename=${encodeURIComponent(name)}&type=${directory}`
)
}))
}
// For output directory, use history data like QueueSidebarTab
const historyResponse = await api.getHistory(200)
if (!historyResponse?.History) {
return []
}
const assetItems: AssetItem[] = []
// Process history items using TaskItemImpl like QueueSidebarTab
historyResponse.History.forEach((historyItem: HistoryTaskItem) => {
// Create TaskItemImpl to use the same logic as QueueSidebarTab
const taskItem = new TaskItemImpl(
'History',
historyItem.prompt,
historyItem.status,
historyItem.outputs
)
// Only process completed tasks
if (taskItem.displayStatus === 'Completed' && taskItem.outputs) {
const executionTimeInSeconds = taskItem.executionTimeInSeconds
const executionStartTimestamp = taskItem.executionStartTimestamp
// Process each output using flatOutputs like QueueSidebarTab
taskItem.flatOutputs.forEach((output) => {
// Only include output type files (not temp previews)
if (output.type === 'output' && output.supportsPreview) {
assetItems.push({
id: `${taskItem.promptId}-${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
created_at: executionStartTimestamp
? new Date(executionStartTimestamp).toISOString()
: new Date().toISOString(),
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId: taskItem.promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
...(executionTimeInSeconds && {
executionTimeInSeconds
}),
...(output.format && {
format: output.format
}),
...(taskItem.workflow && {
workflow: taskItem.workflow
})
}
})
}
})
}
})
// Sort by creation date (newest first)
return assetItems.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
console.error(`Error fetching ${directory} assets:`, errorMessage)
error.value = errorMessage
return []
} finally {
loading.value = false
}
}
return {
loading,
error,
fetchMediaList
}
}

View File

@@ -595,6 +595,8 @@
"nodeLibrary": "Node Library",
"workflows": "Workflows",
"templates": "Templates",
"assets": "Assets",
"mediaAssets": "Media Assets",
"labels": {
"queue": "Queue",
"nodes": "Nodes",
@@ -602,8 +604,15 @@
"workflows": "Workflows",
"templates": "Templates",
"console": "Console",
"menu": "Menu"
"menu": "Menu",
"assets": "Assets",
"imported": "Imported",
"generated": "Generated"
},
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",

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

@@ -2,9 +2,7 @@
<CardContainer
ref="cardContainerRef"
role="button"
:aria-label="
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
"
:aria-label="asset ? `${asset.name} - ${fileKind} asset` : 'Loading asset'"
:tabindex="loading ? -1 : 0"
size="mini"
variant="ghost"
@@ -28,16 +26,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(asset.id)"
@play="actions.playAsset(asset.id)"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"
/>
</template>
@@ -79,7 +78,7 @@
<IconTextButton
type="secondary"
size="sm"
:label="context?.outputCount?.toString() ?? '0'"
:label="'0'"
@click.stop="actions.openMoreOutputs(asset?.id || '')"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
@@ -107,11 +106,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 +128,12 @@ 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, getMediaKindFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
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,13 +160,16 @@ 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 } = defineProps<{
asset?: AssetItem
loading?: boolean
selected?: boolean
}>()
const emit = defineEmits<{
zoom: [asset: AssetItem]
}>()
const cardContainerRef = ref<HTMLElement>()
const isVideoPlaying = ref(false)
@@ -179,14 +177,48 @@ 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[0]
const assetType = computed(() => {
return (asset?.tags?.[0] as 'input' | 'output') || 'input'
})
// Determine file type from extension
const fileKind = computed((): MediaKind => {
return getMediaKindFromFilename(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 ||
(asset.user_metadata?.dimensions as
| { width: number; height: number }
| undefined)
}
})
provide(MediaAssetKey, {
asset: toRef(() => asset),
context: toRef(() => context),
asset: toRef(() => adaptedAsset.value),
context: toRef(() => ({ type: assetType.value })),
isVideoPlaying,
showVideoControls
})
@@ -201,8 +233,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 +252,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 ''
@@ -229,36 +269,35 @@ const showHoverActions = computed(
() => !loading && !!asset && isCardOrOverlayHovered.value
)
const showActionsOverlay = computed(
() =>
showHoverActions.value &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showActionsOverlay = false
// const showActionsOverlay = computed(
// () =>
// showHoverActions.value &&
// (!isVideoPlaying.value || isCardOrOverlayHovered.value)
// )
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(
() =>
!loading &&
context?.outputCount &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
() => false // Remove output count for simplified version
)
const handleCardClick = () => {
if (asset) {
actions.selectAsset(asset)
if (adaptedAsset.value) {
actions.selectAsset(adaptedAsset.value)
}
}
@@ -272,7 +311,11 @@ const handleOverlayMouseLeave = () => {
const handleZoomClick = () => {
if (asset) {
galleryStore.openSingle(asset)
emit('zoom', asset)
}
}
const handleImageLoaded = (dimensions: { width: number; height: number }) => {
imageDimensions.value = dimensions
}
</script>

View File

@@ -7,7 +7,9 @@
{{ fileName }}
</h3>
<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>

View File

@@ -1,11 +1,12 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<LazyImage
v-if="asset.src"
<div
class="relative h-full w-full overflow-hidden rounded bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<img
v-if="shouldShowImage"
:src="asset.src"
:alt="asset.name"
:container-class="'aspect-square'"
:image-class="'w-full h-full object-cover'"
class="h-full w-full object-contain"
/>
<div
v-else
@@ -17,11 +18,35 @@
</template>
<script setup lang="ts">
import LazyImage from '@/components/common/LazyImage.vue'
import { useImage } from '@vueuse/core'
import { computed, watch } from 'vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const emit = defineEmits<{
'image-loaded': [dimensions: { width: number; height: number }]
}>()
// Use same image loading logic as AssetCard
const { state, error, isReady } = useImage({
src: asset.src ?? '',
alt: asset.name
})
const shouldShowImage = computed(() => asset.src && !error.value)
// Emit dimensions when image is loaded
watch(isReady, (ready) => {
if (ready && state.value) {
const width = state.value.naturalWidth
const height = state.value.naturalHeight
if (width && height) {
emit('image-loaded', { width, height })
}
}
})
</script>

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
@@ -45,7 +46,8 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
queue: 'menu.queue',
'node-library': 'sideToolbar.nodeLibrary',
'model-library': 'sideToolbar.modelLibrary',
workflows: 'sideToolbar.workflows'
workflows: 'sideToolbar.workflows',
assets: 'sideToolbar.assets'
}
const key = menubarLabelKeys[tab.id]
@@ -102,6 +104,11 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
* Register the core sidebar tabs.
*/
const registerCoreSidebarTabs = () => {
// Only show AssetsSidebarTab in development mode
if (import.meta.env.DEV) {
registerSidebarTab(useAssetsSidebarTab())
}
registerSidebarTab(useQueueSidebarTab())
registerSidebarTab(useNodeLibrarySidebarTab())
registerSidebarTab(useModelLibrarySidebarTab())