Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/design-system/src/icons/image-ai-edit.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
90 changes: 90 additions & 0 deletions packages/shared-frontend-utils/src/formatUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,93 @@ export function formatDuration(milliseconds: number): string {

return parts.join(' ')
}

// Module scope constants to avoid re-initialization on every call
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi']
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac']
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb']

/**
* Truncates a filename while preserving the extension
* @param filename The filename to truncate
* @param maxLength Maximum length for the filename without extension
* @returns Truncated filename with extension preserved
*/
export function truncateFilename(
filename: string,
maxLength: number = 20
): string {
if (!filename || filename.length <= maxLength) {
return filename
}

const lastDotIndex = filename.lastIndexOf('.')
const nameWithoutExt =
lastDotIndex > -1 ? filename.substring(0, lastDotIndex) : filename
const extension = lastDotIndex > -1 ? filename.substring(lastDotIndex) : ''

// If the name without extension is short enough, return as is
if (nameWithoutExt.length <= maxLength) {
return filename
}

// Calculate how to split the truncation
const halfLength = Math.floor((maxLength - 3) / 2) // -3 for '...'
const start = nameWithoutExt.substring(0, halfLength)
const end = nameWithoutExt.substring(nameWithoutExt.length - halfLength)

return `${start}...${end}${extension}`
}

/**
* Determines the media type from a filename's extension (singular form)
* @param filename The filename to analyze
* @returns The media type: 'image', 'video', 'audio', or '3D'
*/
export function getMediaTypeFromFilename(
filename: string
): 'image' | 'video' | 'audio' | '3D' {
if (!filename) return 'image'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'

if (IMAGE_EXTENSIONS.includes(ext)) return 'image'
if (VIDEO_EXTENSIONS.includes(ext)) return 'video'
if (AUDIO_EXTENSIONS.includes(ext)) return 'audio'
if (THREE_D_EXTENSIONS.includes(ext)) return '3D'

return 'image'
}

/**
* @deprecated Use getMediaTypeFromFilename instead - returns plural form for legacy compatibility
* @param filename The filename to analyze
* @returns The media type in plural form: 'images', 'videos', 'audios', '3D'
*/
export function getMediaTypeFromFilenamePlural(filename: string): string {
const type = getMediaTypeFromFilename(filename)
switch (type) {
case 'image':
return 'images'
case 'video':
return 'videos'
case 'audio':
return 'audios'
case '3D':
return '3D'
default:
return 'images'
}
}

/**
* @deprecated Use getMediaTypeFromFilename instead - kept for backward compatibility
* @param filename The filename to analyze
* @returns The media kind: 'image', 'video', 'audio', or '3D'
*/
export function getMediaKindFromFilename(
filename: string
): 'image' | 'video' | 'audio' | '3D' {
return getMediaTypeFromFilename(filename)
}
2 changes: 1 addition & 1 deletion src/components/card/CardTop.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const {
}>()

const topStyle = computed(() => {
const baseClasses = 'relative p-0'
const baseClasses = 'relative p-0 overflow-hidden'

const ratioClasses = {
square: 'aspect-square',
Expand Down
145 changes: 145 additions & 0 deletions src/components/sidebar/tabs/AssetsSidebarTab.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<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 MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { useMediaAssets } from '@/platform/assets/composables/useMediaAssets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { ResultItemImpl } from '@/stores/queueStore'
import { getMediaTypeFromFilenamePlural } from '@/utils/formatUtil'

const activeTab = ref<'input' | 'output'>('input')
const mediaAssets = ref<AssetItem[]>([])
const selectedAsset = ref<AssetItem | null>(null)

// Use unified media assets implementation that handles cloud/internal automatically
const { loading, error, fetchMediaList } = useMediaAssets()

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: getMediaTypeFromFilenamePlural(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 changes: 16 additions & 0 deletions src/composables/sidebarTabs/useAssetsSidebarTab.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
13 changes: 11 additions & 2 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -588,15 +588,24 @@
"nodeLibrary": "Node Library",
"workflows": "Workflows",
"templates": "Templates",
"assets": "Assets",
"mediaAssets": "Media Assets",
"labels": {
"queue": "Queue",
"nodes": "Nodes",
"models": "Models",
"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",
Expand Down
Loading