mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 16:40:05 +00:00
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:
150
src/components/sidebar/tabs/AssetsSidebarTab.vue
Normal file
150
src/components/sidebar/tabs/AssetsSidebarTab.vue
Normal 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>
|
||||
16
src/composables/sidebarTabs/useAssetsSidebarTab.ts
Normal file
16
src/composables/sidebarTabs/useAssetsSidebarTab.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
133
src/composables/useCloudMediaAssets.ts
Normal file
133
src/composables/useCloudMediaAssets.ts
Normal 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
|
||||
}
|
||||
}
|
||||
129
src/composables/useInternalMediaAssets.ts
Normal file
129
src/composables/useInternalMediaAssets.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -581,13 +581,24 @@
|
||||
"nodeLibrary": "Node Library",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"assets": "Assets",
|
||||
"mediaAssets": "Media Assets",
|
||||
"labels": {
|
||||
"queue": "Queue",
|
||||
"nodes": "Nodes",
|
||||
"models": "Models",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates"
|
||||
"templates": "Templates",
|
||||
"console": "Console",
|
||||
"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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user