Skip to content

Commit be16171

Browse files
viva-jinyiclaude
andcommitted
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 <[email protected]>
1 parent 26f587c commit be16171

File tree

12 files changed

+719
-158
lines changed

12 files changed

+719
-158
lines changed
Lines changed: 5 additions & 0 deletions
Loading

packages/shared-frontend-utils/src/formatUtil.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,3 +474,51 @@ export function formatDuration(milliseconds: number): string {
474474

475475
return parts.join(' ')
476476
}
477+
478+
/**
479+
* Determines the media type from a filename's extension
480+
* @param filename The filename to analyze
481+
* @returns The media type: 'images', 'videos', 'audios', '3D' for gallery compatibility
482+
*/
483+
export function getMediaTypeFromFilename(filename: string): string {
484+
if (!filename) return 'images'
485+
const ext = filename.split('.').pop()?.toLowerCase()
486+
if (!ext) return 'images'
487+
488+
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']
489+
const videoExts = ['mp4', 'webm', 'mov', 'avi']
490+
const audioExts = ['mp3', 'wav', 'ogg', 'flac']
491+
const threeDExts = ['obj', 'fbx', 'gltf', 'glb']
492+
493+
if (imageExts.includes(ext)) return 'images'
494+
if (videoExts.includes(ext)) return 'videos'
495+
if (audioExts.includes(ext)) return 'audios'
496+
if (threeDExts.includes(ext)) return '3D'
497+
498+
return 'images'
499+
}
500+
501+
/**
502+
* Determines the media kind from a filename's extension
503+
* @param filename The filename to analyze
504+
* @returns The media kind: 'image', 'video', 'audio', or '3D'
505+
*/
506+
export function getMediaKindFromFilename(
507+
filename: string
508+
): 'image' | 'video' | 'audio' | '3D' {
509+
if (!filename) return 'image'
510+
const ext = filename.split('.').pop()?.toLowerCase()
511+
if (!ext) return 'image'
512+
513+
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']
514+
const videoExts = ['mp4', 'webm', 'mov', 'avi']
515+
const audioExts = ['mp3', 'wav', 'ogg', 'flac']
516+
const threeDExts = ['obj', 'fbx', 'gltf', 'glb']
517+
518+
if (imageExts.includes(ext)) return 'image'
519+
if (videoExts.includes(ext)) return 'video'
520+
if (audioExts.includes(ext)) return 'audio'
521+
if (threeDExts.includes(ext)) return '3D'
522+
523+
return 'image'
524+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<template>
2+
<SidebarTabTemplate :title="$t('sideToolbar.mediaAssets')">
3+
<template #header>
4+
<Tabs v-model:value="activeTab" class="w-full">
5+
<TabList class="border-b border-neutral-300">
6+
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
7+
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
8+
</TabList>
9+
</Tabs>
10+
</template>
11+
<template #body>
12+
<VirtualGrid
13+
v-if="mediaAssets.length"
14+
:items="mediaAssetsWithKey"
15+
:grid-style="{
16+
display: 'grid',
17+
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
18+
padding: '0.5rem',
19+
gap: '0.5rem'
20+
}"
21+
>
22+
<template #item="{ item }">
23+
<MediaAssetCard
24+
:asset="item"
25+
:selected="selectedAsset?.id === item.id"
26+
@click="handleAssetSelect(item)"
27+
@zoom="handleZoomClick(item)"
28+
/>
29+
</template>
30+
</VirtualGrid>
31+
<div v-else-if="loading">
32+
<ProgressSpinner
33+
style="width: 50px; left: 50%; transform: translateX(-50%)"
34+
/>
35+
</div>
36+
<div v-else>
37+
<NoResultsPlaceholder
38+
icon="pi pi-info-circle"
39+
:title="
40+
$t(
41+
activeTab === 'input'
42+
? 'sideToolbar.noImportedFiles'
43+
: 'sideToolbar.noGeneratedFiles'
44+
)
45+
"
46+
:message="$t('sideToolbar.noFilesFoundMessage')"
47+
/>
48+
</div>
49+
</template>
50+
</SidebarTabTemplate>
51+
<ResultGallery
52+
v-model:active-index="galleryActiveIndex"
53+
:all-gallery-items="galleryItems"
54+
/>
55+
</template>
56+
57+
<script setup lang="ts">
58+
import ProgressSpinner from 'primevue/progressspinner'
59+
import Tab from 'primevue/tab'
60+
import TabList from 'primevue/tablist'
61+
import Tabs from 'primevue/tabs'
62+
import { computed, onMounted, ref, watch } from 'vue'
63+
64+
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
65+
import VirtualGrid from '@/components/common/VirtualGrid.vue'
66+
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
67+
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
68+
import { useCloudMediaAssets } from '@/composables/useCloudMediaAssets'
69+
import { useInternalMediaAssets } from '@/composables/useInternalMediaAssets'
70+
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
71+
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
72+
import { isCloud } from '@/platform/distribution/types'
73+
import { ResultItemImpl } from '@/stores/queueStore'
74+
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
75+
76+
const activeTab = ref<'input' | 'output'>('input')
77+
const mediaAssets = ref<AssetItem[]>([])
78+
const selectedAsset = ref<AssetItem | null>(null)
79+
80+
// Use appropriate implementation based on environment
81+
const implementation = isCloud
82+
? useCloudMediaAssets()
83+
: useInternalMediaAssets()
84+
const { loading, error, fetchMediaList } = implementation
85+
86+
const galleryActiveIndex = ref(-1)
87+
const galleryItems = computed(() => {
88+
// Convert AssetItems to ResultItemImpl format for gallery
89+
return mediaAssets.value.map((asset) => {
90+
const resultItem = new ResultItemImpl({
91+
filename: asset.name,
92+
subfolder: '',
93+
type: 'output',
94+
nodeId: '0',
95+
mediaType: getMediaTypeFromFilename(asset.name)
96+
})
97+
98+
// Override the url getter to use asset.preview_url
99+
Object.defineProperty(resultItem, 'url', {
100+
get() {
101+
return asset.preview_url || ''
102+
},
103+
configurable: true
104+
})
105+
106+
return resultItem
107+
})
108+
})
109+
110+
// Add key property for VirtualGrid
111+
const mediaAssetsWithKey = computed(() => {
112+
return mediaAssets.value.map((asset) => ({
113+
...asset,
114+
key: asset.id
115+
}))
116+
})
117+
118+
const refreshAssets = async () => {
119+
const files = await fetchMediaList(activeTab.value)
120+
mediaAssets.value = files
121+
if (error.value) {
122+
console.error('Failed to refresh assets:', error.value)
123+
}
124+
}
125+
126+
watch(activeTab, () => {
127+
void refreshAssets()
128+
})
129+
130+
onMounted(() => {
131+
void refreshAssets()
132+
})
133+
134+
const handleAssetSelect = (asset: AssetItem) => {
135+
// Toggle selection
136+
if (selectedAsset.value?.id === asset.id) {
137+
selectedAsset.value = null
138+
} else {
139+
selectedAsset.value = asset
140+
}
141+
}
142+
143+
const handleZoomClick = (asset: AssetItem) => {
144+
// Find the index of the clicked asset
145+
const index = mediaAssets.value.findIndex((a) => a.id === asset.id)
146+
if (index !== -1) {
147+
galleryActiveIndex.value = index
148+
}
149+
}
150+
</script>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { markRaw } from 'vue'
2+
3+
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
4+
import type { SidebarTabExtension } from '@/types/extensionTypes'
5+
6+
export const useAssetsSidebarTab = (): SidebarTabExtension => {
7+
return {
8+
id: 'assets',
9+
icon: 'icon-[comfy--image-ai-edit]',
10+
title: 'sideToolbar.assets',
11+
tooltip: 'sideToolbar.assets',
12+
label: 'sideToolbar.labels.assets',
13+
component: markRaw(AssetsSidebarTab),
14+
type: 'vue'
15+
}
16+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { ref } from 'vue'
2+
3+
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
4+
import { assetService } from '@/platform/assets/services/assetService'
5+
import type { HistoryTaskItem } from '@/schemas/apiSchema'
6+
import { api } from '@/scripts/api'
7+
import { TaskItemImpl } from '@/stores/queueStore'
8+
9+
/**
10+
* Composable for fetching media assets from cloud environment
11+
* Includes execution time from history API
12+
*/
13+
export function useCloudMediaAssets() {
14+
const loading = ref(false)
15+
const error = ref<string | null>(null)
16+
17+
/**
18+
* Fetch list of assets from cloud with execution time
19+
* @param directory - 'input' or 'output'
20+
* @returns Array of AssetItem with execution time in user_metadata
21+
*/
22+
const fetchMediaList = async (
23+
directory: 'input' | 'output'
24+
): Promise<AssetItem[]> => {
25+
loading.value = true
26+
error.value = null
27+
28+
try {
29+
// For input directory, just return assets without history
30+
if (directory === 'input') {
31+
const assets = await assetService.getAssetsByTag(directory)
32+
return assets
33+
}
34+
35+
// For output directory, fetch history data and convert to AssetItem format
36+
const historyResponse = await api.getHistory(200)
37+
38+
if (!historyResponse?.History) {
39+
return []
40+
}
41+
42+
// Convert history items to AssetItem format
43+
const assetItems: AssetItem[] = []
44+
45+
historyResponse.History.forEach((historyItem: HistoryTaskItem) => {
46+
// Create TaskItemImpl to use existing logic
47+
const taskItem = new TaskItemImpl(
48+
historyItem.taskType,
49+
historyItem.prompt,
50+
historyItem.status,
51+
historyItem.outputs
52+
)
53+
54+
// Only process completed tasks
55+
if (taskItem.displayStatus === 'Completed' && taskItem.outputs) {
56+
// Get execution time
57+
const executionTimeInSeconds = taskItem.executionTimeInSeconds
58+
59+
// Process each output
60+
taskItem.flatOutputs.forEach((output) => {
61+
// Only include output type files (not temp previews)
62+
if (output.type === 'output' && output.supportsPreview) {
63+
// Truncate filename if longer than 15 characters
64+
let displayName = output.filename
65+
if (output.filename.length > 20) {
66+
// Get file extension
67+
const lastDotIndex = output.filename.lastIndexOf('.')
68+
const nameWithoutExt =
69+
lastDotIndex > -1
70+
? output.filename.substring(0, lastDotIndex)
71+
: output.filename
72+
const extension =
73+
lastDotIndex > -1
74+
? output.filename.substring(lastDotIndex)
75+
: ''
76+
77+
// If name without extension is still long, truncate it
78+
if (nameWithoutExt.length > 10) {
79+
displayName =
80+
nameWithoutExt.substring(0, 10) +
81+
'...' +
82+
nameWithoutExt.substring(nameWithoutExt.length - 10) +
83+
extension
84+
}
85+
}
86+
87+
assetItems.push({
88+
id: `${taskItem.promptId}-${output.nodeId}-${output.filename}`,
89+
name: displayName,
90+
size: 0, // We don't have size info from history
91+
created_at: taskItem.executionStartTimestamp
92+
? new Date(taskItem.executionStartTimestamp).toISOString()
93+
: new Date().toISOString(),
94+
tags: ['output'],
95+
preview_url: output.url,
96+
user_metadata: {
97+
originalFilename: output.filename, // Store original filename
98+
promptId: taskItem.promptId,
99+
nodeId: output.nodeId,
100+
subfolder: output.subfolder,
101+
...(executionTimeInSeconds && {
102+
executionTimeInSeconds
103+
}),
104+
...(output.format && {
105+
format: output.format
106+
}),
107+
...(taskItem.workflow && {
108+
workflow: taskItem.workflow
109+
})
110+
}
111+
})
112+
}
113+
})
114+
}
115+
})
116+
117+
return assetItems
118+
} catch (err) {
119+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
120+
console.error(`Error fetching ${directory} cloud assets:`, errorMessage)
121+
error.value = errorMessage
122+
return []
123+
} finally {
124+
loading.value = false
125+
}
126+
}
127+
128+
return {
129+
loading,
130+
error,
131+
fetchMediaList
132+
}
133+
}

0 commit comments

Comments
 (0)