mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-30 01:05:46 +00:00
Compare commits
13 Commits
coderabbit
...
feature/as
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754eb9c3b4 | ||
|
|
354c05ea68 | ||
|
|
1ee12833f4 | ||
|
|
d064c489e4 | ||
|
|
39dad98d87 | ||
|
|
8682ca3e5c | ||
|
|
72e130decf | ||
|
|
bd23cccde3 | ||
|
|
de8b6477b0 | ||
|
|
207fcc3ced | ||
|
|
3776c3265c | ||
|
|
dcd6bb6519 | ||
|
|
2398e26712 |
5
packages/design-system/src/icons/image-ai-edit.svg
Normal file
5
packages/design-system/src/icons/image-ai-edit.svg
Normal 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 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
33
src/components/sidebar/tabs/AssetSidebarTemplate.vue
Normal file
33
src/components/sidebar/tabs/AssetSidebarTemplate.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col bg-interface-panel-surface"
|
||||
:class="props.class"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
v-if="slots.top"
|
||||
class="flex min-h-12 items-center border-b border-interface-stroke px-4 py-2"
|
||||
>
|
||||
<slot name="top" />
|
||||
</div>
|
||||
<div v-if="slots.header" class="px-4">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- h-0 to force scrollpanel to grow -->
|
||||
<ScrollPanel class="h-0 grow">
|
||||
<slot name="body" />
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useSlots } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
</script>
|
||||
293
src/components/sidebar/tabs/AssetsSidebarTab.vue
Normal file
293
src/components/sidebar/tabs/AssetsSidebarTab.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<AssetsSidebarTemplate>
|
||||
<template #top>
|
||||
<span v-if="!isInFolderView" class="font-bold">
|
||||
{{ $t('sideToolbar.mediaAssets') }}
|
||||
</span>
|
||||
<div v-else class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ $t('Job ID') }}:</span>
|
||||
<span class="text-sm">{{ folderPromptId?.substring(0, 8) }}</span>
|
||||
<button
|
||||
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
|
||||
role="button"
|
||||
@click="copyJobId"
|
||||
>
|
||||
<i class="mb-1 icon-[lucide--copy] text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ formattedExecutionTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #header>
|
||||
<!-- Job Detail View Header -->
|
||||
<div v-if="isInFolderView" class="pt-4 pb-1">
|
||||
<IconTextButton
|
||||
:label="$t('sideToolbar.backToAssets')"
|
||||
type="secondary"
|
||||
@click="exitFolderView"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-left] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<!-- Normal Tab View -->
|
||||
<TabList v-else v-model="activeTab" class="pt-4 pb-1">
|
||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
||||
</TabList>
|
||||
</template>
|
||||
<template #body>
|
||||
<VirtualGrid
|
||||
v-if="displayAssets.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"
|
||||
:show-output-count="
|
||||
activeTab === 'output' &&
|
||||
!isInFolderView &&
|
||||
(item.user_metadata?.outputCount as number) > 1
|
||||
"
|
||||
:output-count="(item.user_metadata?.outputCount as number) || 0"
|
||||
@click="handleAssetSelect(item)"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@asset-deleted="refreshAssets"
|
||||
/>
|
||||
</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>
|
||||
</AssetsSidebarTemplate>
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.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 {
|
||||
formatDuration,
|
||||
getMediaTypeFromFilenamePlural
|
||||
} from '@/utils/formatUtil'
|
||||
|
||||
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('input')
|
||||
const mediaAssets = ref<AssetItem[]>([])
|
||||
const selectedAsset = ref<AssetItem | null>(null)
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
|
||||
const formattedExecutionTime = computed(() => {
|
||||
if (!folderExecutionTime.value) return ''
|
||||
return formatDuration(folderExecutionTime.value * 1000)
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// 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
|
||||
// Use displayAssets instead of mediaAssets to show correct items based on view mode
|
||||
return displayAssets.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
|
||||
})
|
||||
})
|
||||
|
||||
// Group assets by promptId for output tab
|
||||
const groupedAssets = computed(() => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const groups = new Map<string, AssetItem[]>()
|
||||
|
||||
mediaAssets.value.forEach((asset) => {
|
||||
const promptId = asset.user_metadata?.promptId as string
|
||||
if (promptId) {
|
||||
if (!groups.has(promptId)) {
|
||||
groups.set(promptId, [])
|
||||
}
|
||||
groups.get(promptId)!.push(asset)
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// Get display assets based on view mode
|
||||
const displayAssets = computed(() => {
|
||||
if (isInFolderView.value && folderPromptId.value) {
|
||||
// Show all assets from the selected prompt
|
||||
return mediaAssets.value.filter(
|
||||
(asset) => asset.user_metadata?.promptId === folderPromptId.value
|
||||
)
|
||||
}
|
||||
|
||||
if (activeTab.value === 'output' && groupedAssets.value) {
|
||||
// Show only the first asset from each prompt group
|
||||
const firstAssets: AssetItem[] = []
|
||||
groupedAssets.value.forEach((assets) => {
|
||||
if (assets.length > 0) {
|
||||
// Add output count to the first asset
|
||||
const firstAsset = { ...assets[0] }
|
||||
firstAsset.user_metadata = {
|
||||
...firstAsset.user_metadata,
|
||||
outputCount: assets.length
|
||||
}
|
||||
firstAssets.push(firstAsset)
|
||||
}
|
||||
})
|
||||
return firstAssets
|
||||
}
|
||||
|
||||
return mediaAssets.value
|
||||
})
|
||||
|
||||
// Add key property for VirtualGrid
|
||||
const mediaAssetsWithKey = computed(() => {
|
||||
return displayAssets.value.map((asset) => ({
|
||||
...asset,
|
||||
key: asset.id
|
||||
}))
|
||||
})
|
||||
|
||||
const refreshAssets = async () => {
|
||||
const files = await fetchMediaList(activeTab.value)
|
||||
mediaAssets.value = files
|
||||
selectedAsset.value = null // Clear selection after refresh
|
||||
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 = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
if (index !== -1) {
|
||||
galleryActiveIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
const enterFolderView = (asset: AssetItem) => {
|
||||
const promptId = asset.user_metadata?.promptId as string
|
||||
if (promptId) {
|
||||
folderPromptId.value = promptId
|
||||
// Get execution time from the first asset of this prompt
|
||||
const promptAssets = mediaAssets.value.filter(
|
||||
(a) => a.user_metadata?.promptId === promptId
|
||||
)
|
||||
if (promptAssets.length > 0) {
|
||||
folderExecutionTime.value = promptAssets[0].user_metadata
|
||||
?.executionTimeInSeconds as number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const exitFolderView = () => {
|
||||
folderPromptId.value = null
|
||||
folderExecutionTime.value = undefined
|
||||
}
|
||||
|
||||
const copyJobId = async () => {
|
||||
if (folderPromptId.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(folderPromptId.value)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Copied',
|
||||
detail: 'Job ID copied to clipboard',
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to copy Job ID',
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
43
src/components/tab/Tab.vue
Normal file
43
src/components/tab/Tab.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<button
|
||||
:class="tabClasses"
|
||||
role="tab"
|
||||
:aria-selected="isActive"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { value } = defineProps<{
|
||||
value: string
|
||||
}>()
|
||||
|
||||
const currentValue = inject<Ref<string>>('tabs-value')
|
||||
const updateValue = inject<(value: string) => void>('tabs-update')
|
||||
|
||||
const isActive = computed(() => currentValue?.value === value)
|
||||
|
||||
const tabClasses = computed(() => {
|
||||
return cn(
|
||||
// Base styles from TextButton
|
||||
'flex items-center justify-center shrink-0',
|
||||
'px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200',
|
||||
'outline-hidden border-none',
|
||||
// State styles with semantic tokens
|
||||
isActive.value
|
||||
? 'bg-interface-menu-component-surface-hovered text-text-primary text-bold'
|
||||
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||
)
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
updateValue?.(value)
|
||||
}
|
||||
</script>
|
||||
153
src/components/tab/TabList.stories.ts
Normal file
153
src/components/tab/TabList.stories.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Tab from './Tab.vue'
|
||||
import TabList from './TabList.vue'
|
||||
|
||||
const meta: Meta<typeof TabList> = {
|
||||
title: 'Components/Tab/TabList',
|
||||
component: TabList,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text',
|
||||
description: 'The currently selected tab value'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref(args.modelValue || 'tab1')
|
||||
return { activeTab }
|
||||
},
|
||||
template: `
|
||||
<TabList v-model="activeTab">
|
||||
<Tab value="tab1">Tab 1</Tab>
|
||||
<Tab value="tab2">Tab 2</Tab>
|
||||
<Tab value="tab3">Tab 3</Tab>
|
||||
</TabList>
|
||||
<div class="mt-4 p-4 border rounded">
|
||||
Selected tab: {{ activeTab }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: 'tab1'
|
||||
}
|
||||
}
|
||||
|
||||
export const ManyTabs: Story = {
|
||||
render: () => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref('tab1')
|
||||
return { activeTab }
|
||||
},
|
||||
template: `
|
||||
<TabList v-model="activeTab">
|
||||
<Tab value="tab1">Dashboard</Tab>
|
||||
<Tab value="tab2">Analytics</Tab>
|
||||
<Tab value="tab3">Reports</Tab>
|
||||
<Tab value="tab4">Settings</Tab>
|
||||
<Tab value="tab5">Profile</Tab>
|
||||
</TabList>
|
||||
<div class="mt-4 p-4 border rounded">
|
||||
Selected tab: {{ activeTab }}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render: () => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref('home')
|
||||
return { activeTab }
|
||||
},
|
||||
template: `
|
||||
<TabList v-model="activeTab">
|
||||
<Tab value="home">
|
||||
<i class="pi pi-home mr-2"></i>
|
||||
Home
|
||||
</Tab>
|
||||
<Tab value="users">
|
||||
<i class="pi pi-users mr-2"></i>
|
||||
Users
|
||||
</Tab>
|
||||
<Tab value="settings">
|
||||
<i class="pi pi-cog mr-2"></i>
|
||||
Settings
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div class="mt-4 p-4 border rounded">
|
||||
Selected tab: {{ activeTab }}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LongLabels: Story = {
|
||||
render: () => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref('overview')
|
||||
return { activeTab }
|
||||
},
|
||||
template: `
|
||||
<TabList v-model="activeTab">
|
||||
<Tab value="overview">Project Overview</Tab>
|
||||
<Tab value="documentation">Documentation & Guides</Tab>
|
||||
<Tab value="deployment">Deployment Settings</Tab>
|
||||
<Tab value="monitoring">Monitoring & Analytics</Tab>
|
||||
</TabList>
|
||||
<div class="mt-4 p-4 border rounded">
|
||||
Selected tab: {{ activeTab }}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: () => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref('input')
|
||||
const handleTabChange = (value: string) => {
|
||||
console.log('Tab changed to:', value)
|
||||
}
|
||||
return { activeTab, handleTabChange }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Example: Media Assets</h3>
|
||||
<TabList v-model="activeTab" @update:model-value="handleTabChange">
|
||||
<Tab value="input">Imported</Tab>
|
||||
<Tab value="output">Generated</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div v-if="activeTab === 'input'">
|
||||
<p>Showing imported assets...</p>
|
||||
</div>
|
||||
<div v-else-if="activeTab === 'output'">
|
||||
<p>Showing generated assets...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600">
|
||||
Current tab value: <code>{{ activeTab }}</code>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
23
src/components/tab/TabList.vue
Normal file
23
src/components/tab/TabList.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center gap-2 pb-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { provide, toRef } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Provide for child Tab components
|
||||
provide('tabs-value', toRef(props, 'modelValue'))
|
||||
provide('tabs-update', (value: string) => emit('update:modelValue', value))
|
||||
</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'
|
||||
}
|
||||
}
|
||||
@@ -595,6 +595,9 @@
|
||||
"nodeLibrary": "Node Library",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"assets": "Assets",
|
||||
"mediaAssets": "Media Assets",
|
||||
"backToAssets": "Back to all assets",
|
||||
"labels": {
|
||||
"queue": "Queue",
|
||||
"nodes": "Nodes",
|
||||
@@ -602,8 +605,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",
|
||||
@@ -2043,6 +2053,8 @@
|
||||
"browseAssets": "Browse Assets",
|
||||
"noAssetsFound": "No assets found",
|
||||
"tryAdjustingFilters": "Try adjusting your search or filters",
|
||||
"deleteAssetTitle": "Delete this asset?",
|
||||
"deleteAssetDescription": "This asset will be permanently removed.",
|
||||
"loadingModels": "Loading {type}...",
|
||||
"connectionError": "Please check your connection and try again",
|
||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||
|
||||
@@ -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,39 +12,80 @@
|
||||
@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 { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
menuStateChanged: [isOpen: boolean]
|
||||
inspect: []
|
||||
'asset-deleted': []
|
||||
}>()
|
||||
|
||||
const { asset } = inject(MediaAssetKey)!
|
||||
const { asset, context } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
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 = () => {
|
||||
if (asset.value) {
|
||||
actions.deleteAsset(asset.value.id)
|
||||
}
|
||||
if (!asset.value?.id || !assetType.value) return
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: 'delete-asset-confirmation',
|
||||
title: t('assetBrowser.deleteAssetTitle'),
|
||||
component: ConfirmationDialogContent,
|
||||
props: {
|
||||
message: t('assetBrowser.deleteAssetDescription'),
|
||||
type: 'delete',
|
||||
itemList: [asset.value.name],
|
||||
onConfirm: async () => {
|
||||
const success = await actions.deleteAsset(
|
||||
asset.value!.id,
|
||||
assetType.value
|
||||
)
|
||||
if (success) {
|
||||
emit('asset-deleted')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
actions.downloadAsset()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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()"
|
||||
@play="actions.playAsset(asset.id)"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
@image-loaded="handleImageLoaded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -45,6 +44,8 @@
|
||||
<template v-if="showActionsOverlay" #top-left>
|
||||
<MediaAssetActions
|
||||
@menu-state-changed="isMenuOpen = $event"
|
||||
@inspect="handleZoomClick"
|
||||
@asset-deleted="handleAssetDelete"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
/>
|
||||
@@ -63,24 +64,31 @@
|
||||
</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>
|
||||
|
||||
<!-- Output count (bottom-right) - show on hover even when playing -->
|
||||
<template v-if="showOutputCount" #bottom-right>
|
||||
<template
|
||||
v-if="showOutputCount && outputCount && outputCount > 1"
|
||||
#bottom-right
|
||||
>
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:label="context?.outputCount?.toString() ?? '0'"
|
||||
@click.stop="actions.openMoreOutputs(asset?.id || '')"
|
||||
:label="String(outputCount || 0)"
|
||||
@click.stop="handleOutputCountClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
@@ -107,11 +115,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 +137,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, getMediaTypeFromFilename } 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,11 +169,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 +190,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 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 ||
|
||||
(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 +246,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 +265,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 +291,31 @@ 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)
|
||||
)
|
||||
|
||||
// Remove the redundant showOutputCount computed since we're using prop directly
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (asset) {
|
||||
actions.selectAsset(asset)
|
||||
if (adaptedAsset.value) {
|
||||
actions.selectAsset(adaptedAsset.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +329,19 @@ const handleOverlayMouseLeave = () => {
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
galleryStore.openSingle(asset)
|
||||
emit('zoom', asset)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageLoaded = (dimensions: { width: number; height: number }) => {
|
||||
imageDimensions.value = dimensions
|
||||
}
|
||||
|
||||
const handleOutputCountClick = () => {
|
||||
emit('output-count-click')
|
||||
}
|
||||
|
||||
const handleAssetDelete = () => {
|
||||
emit('asset-deleted')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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"
|
||||
@@ -90,11 +93,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
||||
|
||||
@@ -102,16 +108,38 @@ const { close } = defineProps<{
|
||||
close: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
inspect: []
|
||||
'asset-deleted': []
|
||||
}>()
|
||||
|
||||
const { asset, context } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showWorkflowOptions = computed(() => context.value.type)
|
||||
const assetType = computed(() => {
|
||||
return (asset.value as any)?.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 +152,7 @@ const handleAddToWorkflow = () => {
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
actions.downloadAsset()
|
||||
}
|
||||
close()
|
||||
}
|
||||
@@ -143,17 +171,37 @@ 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)
|
||||
}
|
||||
close()
|
||||
if (!asset.value?.id || !assetType.value) return
|
||||
|
||||
close() // Close the menu first
|
||||
|
||||
// Show confirmation dialog
|
||||
dialogStore.showDialog({
|
||||
key: 'delete-asset-confirmation',
|
||||
title: t('assetBrowser.deleteAssetTitle'),
|
||||
component: ConfirmationDialogContent,
|
||||
props: {
|
||||
message: t('assetBrowser.deleteAssetDescription'),
|
||||
type: 'delete',
|
||||
itemList: [asset.value.name],
|
||||
onConfirm: async () => {
|
||||
const success = await actions.deleteAsset(
|
||||
asset.value!.id,
|
||||
assetType.value as 'input' | 'output'
|
||||
)
|
||||
if (success) {
|
||||
emit('asset-deleted')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<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,7 +15,7 @@
|
||||
<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'
|
||||
|
||||
|
||||
@@ -1,29 +1,151 @@
|
||||
/* eslint-disable no-console */
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { inject } from 'vue'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { extractPromptIdFromAssetId } from '@/utils/uuidUtil'
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import { assetService } from '../services/assetService'
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const toast = useToast()
|
||||
const mediaContext = inject(MediaAssetKey, null)
|
||||
|
||||
const selectAsset = (asset: AssetMeta) => {
|
||||
console.log('Asset selected:', asset)
|
||||
}
|
||||
|
||||
const downloadAsset = (assetId: string) => {
|
||||
console.log('Downloading asset:', assetId)
|
||||
const downloadAsset = () => {
|
||||
const asset = mediaContext?.asset.value
|
||||
if (!asset) return
|
||||
|
||||
try {
|
||||
const assetType = (asset as AssetItem).tags?.[0] || 'output'
|
||||
const filename = asset.name
|
||||
const downloadUrl = api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(filename)}&type=${assetType}`
|
||||
)
|
||||
|
||||
downloadFile(downloadUrl, filename)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('g.downloadStarted'),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAsset = (assetId: string) => {
|
||||
console.log('Deleting asset:', assetId)
|
||||
const deleteAsset = async (
|
||||
assetId: string,
|
||||
assetType: 'input' | 'output'
|
||||
) => {
|
||||
try {
|
||||
if (assetType === 'output') {
|
||||
// For output files, delete from history
|
||||
const promptId = extractPromptIdFromAssetId(assetId)
|
||||
if (!promptId) {
|
||||
throw new Error('Unable to extract prompt ID from asset')
|
||||
}
|
||||
|
||||
await api.deleteItem('history', promptId)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: 'Asset deleted successfully',
|
||||
life: 2000
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
// For input files, only allow deletion in cloud environment
|
||||
if (!isCloud) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail:
|
||||
'Deleting imported files is only supported in cloud version',
|
||||
life: 3000
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// In cloud environment, use the assets API to delete
|
||||
await assetService.deleteAsset(assetId)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: 'Asset deleted successfully',
|
||||
life: 2000
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
throw new Error('Unable to determine asset type')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete asset:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail:
|
||||
error instanceof Error ? error.message : 'Failed to delete asset',
|
||||
life: 3000
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const playAsset = (assetId: string) => {
|
||||
console.log('Playing asset:', assetId)
|
||||
}
|
||||
|
||||
const copyAssetUrl = (assetId: string) => {
|
||||
console.log('Copy asset URL:', assetId)
|
||||
}
|
||||
const copyJobId = async () => {
|
||||
const asset = mediaContext?.asset.value
|
||||
if (!asset) return
|
||||
|
||||
const copyJobId = (jobId: string) => {
|
||||
console.log('Copy job ID:', jobId)
|
||||
const promptId = extractPromptIdFromAssetId(asset.id)
|
||||
|
||||
if (!promptId) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: 'No job ID found for this asset',
|
||||
life: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(promptId)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: 'Job ID copied to clipboard',
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: 'Failed to copy job ID',
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addWorkflow = (assetId: string) => {
|
||||
@@ -47,7 +169,6 @@ export function useMediaAssetActions() {
|
||||
downloadAsset,
|
||||
deleteAsset,
|
||||
playAsset,
|
||||
copyAssetUrl,
|
||||
copyJobId,
|
||||
addWorkflow,
|
||||
openWorkflow,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Interface for media assets providers
|
||||
* Defines the common API for both cloud and internal file implementations
|
||||
*/
|
||||
export interface IAssetsProvider {
|
||||
/** Loading state indicator */
|
||||
loading: Ref<boolean>
|
||||
|
||||
/** Error state, null when no error */
|
||||
error: Ref<string | null>
|
||||
|
||||
/**
|
||||
* Fetch list of media assets from the specified directory
|
||||
* @param directory - 'input' or 'output'
|
||||
* @returns Promise resolving to array of AssetItem
|
||||
*/
|
||||
fetchMediaList: (directory: 'input' | 'output') => Promise<AssetItem[]>
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { truncateFilename } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Maps a TaskItemImpl output to an AssetItem format
|
||||
* @param taskItem The task item containing execution data
|
||||
* @param output The output from the task
|
||||
* @param useDisplayName Whether to truncate the filename for display
|
||||
* @returns AssetItem formatted object
|
||||
*/
|
||||
export function mapTaskOutputToAssetItem(
|
||||
taskItem: TaskItemImpl,
|
||||
output: any,
|
||||
useDisplayName: boolean = false
|
||||
): AssetItem {
|
||||
const metadata: Record<string, any> = {
|
||||
promptId: taskItem.promptId,
|
||||
nodeId: output.nodeId,
|
||||
subfolder: output.subfolder
|
||||
}
|
||||
|
||||
// Add execution time if available
|
||||
if (taskItem.executionTimeInSeconds) {
|
||||
metadata.executionTimeInSeconds = taskItem.executionTimeInSeconds
|
||||
}
|
||||
|
||||
// Add format if available
|
||||
if (output.format) {
|
||||
metadata.format = output.format
|
||||
}
|
||||
|
||||
// Add workflow if available
|
||||
if (taskItem.workflow) {
|
||||
metadata.workflow = taskItem.workflow
|
||||
}
|
||||
|
||||
// Store original filename if using display name
|
||||
if (useDisplayName) {
|
||||
metadata.originalFilename = output.filename
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${taskItem.promptId}-${output.nodeId}-${output.filename}`,
|
||||
name: useDisplayName
|
||||
? truncateFilename(output.filename, 20)
|
||||
: output.filename,
|
||||
size: 0, // Size not available from history API
|
||||
created_at: taskItem.executionStartTimestamp
|
||||
? new Date(taskItem.executionStartTimestamp).toISOString()
|
||||
: new Date().toISOString(),
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: metadata
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps input directory file to AssetItem format
|
||||
* @param filename The filename
|
||||
* @param index File index for unique ID
|
||||
* @param directory The directory type
|
||||
* @returns AssetItem formatted object
|
||||
*/
|
||||
export function mapInputFileToAssetItem(
|
||||
filename: string,
|
||||
index: number,
|
||||
directory: 'input' | 'output' = 'input'
|
||||
): AssetItem {
|
||||
return {
|
||||
id: `${directory}-${index}-${filename}`,
|
||||
name: filename,
|
||||
size: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: [directory],
|
||||
preview_url: api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(filename)}&type=${directory}`
|
||||
)
|
||||
}
|
||||
}
|
||||
14
src/platform/assets/composables/useMediaAssets/index.ts
Normal file
14
src/platform/assets/composables/useMediaAssets/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import type { IAssetsProvider } from './IAssetsProvider'
|
||||
import { useAssetsApi } from './useAssetsApi'
|
||||
import { useInternalFilesApi } from './useInternalFilesApi'
|
||||
|
||||
/**
|
||||
* Factory function that returns the appropriate media assets implementation
|
||||
* based on the current distribution (cloud vs internal)
|
||||
* @returns IAssetsProvider implementation
|
||||
*/
|
||||
export function useMediaAssets(): IAssetsProvider {
|
||||
return isCloud ? useAssetsApi() : useInternalFilesApi()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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'
|
||||
import { truncateFilename } from '@/utils/formatUtil'
|
||||
|
||||
import { mapTaskOutputToAssetItem } from './assetMappers'
|
||||
|
||||
/**
|
||||
* Composable for fetching media assets from cloud environment
|
||||
* Includes execution time from history API
|
||||
*/
|
||||
export function useAssetsApi() {
|
||||
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, false)
|
||||
// Process assets to truncate long filenames for display
|
||||
return assets.map((asset) => ({
|
||||
...asset,
|
||||
name: truncateFilename(asset.name, 20),
|
||||
user_metadata: {
|
||||
...asset.user_metadata,
|
||||
originalFilename: asset.name.length > 20 ? asset.name : undefined
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Process each output
|
||||
taskItem.flatOutputs.forEach((output) => {
|
||||
// Only include output type files (not temp previews)
|
||||
if (output.type === 'output' && output.supportsPreview) {
|
||||
const assetItem = mapTaskOutputToAssetItem(
|
||||
taskItem,
|
||||
output,
|
||||
true // Use display name for cloud
|
||||
)
|
||||
assetItems.push(assetItem)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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'
|
||||
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
mapTaskOutputToAssetItem
|
||||
} from './assetMappers'
|
||||
|
||||
/**
|
||||
* Composable for fetching media assets from local environment
|
||||
* Uses the same logic as QueueSidebarTab for history processing
|
||||
*/
|
||||
export function useInternalFilesApi() {
|
||||
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) =>
|
||||
mapInputFileToAssetItem(name, index, 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) {
|
||||
// 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) {
|
||||
const assetItem = mapTaskOutputToAssetItem(
|
||||
taskItem,
|
||||
output,
|
||||
false // Don't use display name for internal
|
||||
)
|
||||
assetItems.push(assetItem)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,7 @@ const zMediaAssetDisplayItemSchema = assetItemSchema.extend({
|
||||
|
||||
// New optional fields
|
||||
duration: z.number().nonnegative().optional(),
|
||||
dimensions: zDimensionsSchema.optional(),
|
||||
jobId: z.string().optional(),
|
||||
isMulti: z.boolean().optional()
|
||||
dimensions: zDimensionsSchema.optional()
|
||||
})
|
||||
|
||||
// Asset context schema
|
||||
|
||||
@@ -190,11 +190,21 @@ function createAssetService() {
|
||||
* Gets assets filtered by a specific tag
|
||||
*
|
||||
* @param tag - The tag to filter by (e.g., 'models')
|
||||
* @param includePublic - Whether to include public assets (default: true)
|
||||
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
|
||||
*/
|
||||
async function getAssetsByTag(tag: string): Promise<AssetItem[]> {
|
||||
async function getAssetsByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true
|
||||
): Promise<AssetItem[]> {
|
||||
const queryParams = new URLSearchParams({
|
||||
include_tags: tag,
|
||||
limit: DEFAULT_LIMIT.toString(),
|
||||
include_public: includePublic ? 'true' : 'false'
|
||||
})
|
||||
|
||||
const data = await handleAssetRequest(
|
||||
`${ASSETS_ENDPOINT}?include_tags=${tag}&limit=${DEFAULT_LIMIT}`,
|
||||
`${ASSETS_ENDPOINT}?${queryParams.toString()}`,
|
||||
`assets for tag ${tag}`
|
||||
)
|
||||
|
||||
@@ -203,13 +213,34 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an asset by ID
|
||||
* Only available in cloud environment
|
||||
*
|
||||
* @param id - The asset ID (UUID)
|
||||
* @returns Promise<void>
|
||||
* @throws Error if deletion fails
|
||||
*/
|
||||
async function deleteAsset(id: string): Promise<void> {
|
||||
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to delete asset ${id}: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
isAssetBrowserEligible,
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag
|
||||
getAssetsByTag,
|
||||
deleteAsset
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
41
src/utils/uuidUtil.ts
Normal file
41
src/utils/uuidUtil.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* UUID utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regular expression for matching UUID v4 format
|
||||
* Format: 8-4-4-4-12 (e.g., 98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3)
|
||||
*/
|
||||
const UUID_REGEX =
|
||||
/^([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i
|
||||
|
||||
/**
|
||||
* Extract UUID from the beginning of a string
|
||||
* @param str - The string to extract UUID from
|
||||
* @returns The extracted UUID or null if not found
|
||||
*/
|
||||
export function extractUuidFromString(str: string): string | null {
|
||||
const match = str.match(UUID_REGEX)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid UUID v4
|
||||
* @param str - The string to check
|
||||
* @returns true if the string is a valid UUID v4
|
||||
*/
|
||||
export function isValidUuid(str: string): boolean {
|
||||
const fullUuidRegex =
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
|
||||
return fullUuidRegex.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt ID from asset ID
|
||||
* Asset ID format: "promptId-nodeId-filename"
|
||||
* @param assetId - The asset ID string
|
||||
* @returns The extracted prompt ID (UUID) or null
|
||||
*/
|
||||
export function extractPromptIdFromAssetId(assetId: string): string | null {
|
||||
return extractUuidFromString(assetId)
|
||||
}
|
||||
@@ -297,7 +297,7 @@ describe('assetService', () => {
|
||||
const result = await assetService.getAssetsByTag('models')
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models&limit=500'
|
||||
'/assets?include_tags=models&limit=500&include_public=true'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
@@ -355,5 +355,29 @@ describe('assetService', () => {
|
||||
expect(result[0]).toHaveProperty('asset_hash', 'blake3:full123')
|
||||
expect(result[0]).toHaveProperty('user_metadata')
|
||||
})
|
||||
|
||||
it('should exclude public assets when includePublic is false', async () => {
|
||||
const testAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('input', false)
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=input&limit=500&include_public=false'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
it('should include public assets when includePublic is true', async () => {
|
||||
const testAssets = [MOCK_ASSETS.checkpoints, MOCK_ASSETS.loras]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('models', true)
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models&limit=500&include_public=true'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
147
tests-ui/tests/utils/formatUtil.test.ts
Normal file
147
tests-ui/tests/utils/formatUtil.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getMediaTypeFromFilename,
|
||||
getMediaTypeFromFilenamePlural,
|
||||
truncateFilename
|
||||
} from '@/utils/formatUtil'
|
||||
|
||||
describe('formatUtil', () => {
|
||||
describe('truncateFilename', () => {
|
||||
it('should not truncate short filenames', () => {
|
||||
expect(truncateFilename('test.png')).toBe('test.png')
|
||||
expect(truncateFilename('short.jpg', 10)).toBe('short.jpg')
|
||||
})
|
||||
|
||||
it('should truncate long filenames while preserving extension', () => {
|
||||
const longName = 'this-is-a-very-long-filename-that-needs-truncation.png'
|
||||
const truncated = truncateFilename(longName, 20)
|
||||
expect(truncated).toContain('...')
|
||||
expect(truncated.endsWith('.png')).toBe(true)
|
||||
expect(truncated.length).toBeLessThanOrEqual(25) // 20 + '...' + extension
|
||||
})
|
||||
|
||||
it('should handle filenames without extensions', () => {
|
||||
const longName = 'this-is-a-very-long-filename-without-extension'
|
||||
const truncated = truncateFilename(longName, 20)
|
||||
expect(truncated).toContain('...')
|
||||
expect(truncated.length).toBeLessThanOrEqual(23) // 20 + '...'
|
||||
})
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(truncateFilename('')).toBe('')
|
||||
expect(truncateFilename('', 10)).toBe('')
|
||||
})
|
||||
|
||||
it('should preserve the start and end of the filename', () => {
|
||||
const longName = 'ComfyUI_00001_timestamp_2024_01_01.png'
|
||||
const truncated = truncateFilename(longName, 20)
|
||||
expect(truncated).toMatch(/^ComfyUI.*01\.png$/)
|
||||
expect(truncated).toContain('...')
|
||||
})
|
||||
|
||||
it('should handle files with multiple dots', () => {
|
||||
const filename = 'my.file.with.multiple.dots.txt'
|
||||
const truncated = truncateFilename(filename, 15)
|
||||
expect(truncated.endsWith('.txt')).toBe(true)
|
||||
expect(truncated).toContain('...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMediaTypeFromFilename', () => {
|
||||
describe('image files', () => {
|
||||
it('should identify image extensions correctly', () => {
|
||||
expect(getMediaTypeFromFilename('test.png')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('photo.jpg')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('image.jpeg')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('animation.gif')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('web.webp')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('bitmap.bmp')).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle uppercase extensions', () => {
|
||||
expect(getMediaTypeFromFilename('test.PNG')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('photo.JPG')).toBe('image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('video files', () => {
|
||||
it('should identify video extensions correctly', () => {
|
||||
expect(getMediaTypeFromFilename('video.mp4')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('clip.webm')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('movie.mov')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('film.avi')).toBe('video')
|
||||
})
|
||||
})
|
||||
|
||||
describe('audio files', () => {
|
||||
it('should identify audio extensions correctly', () => {
|
||||
expect(getMediaTypeFromFilename('song.mp3')).toBe('audio')
|
||||
expect(getMediaTypeFromFilename('sound.wav')).toBe('audio')
|
||||
expect(getMediaTypeFromFilename('music.ogg')).toBe('audio')
|
||||
expect(getMediaTypeFromFilename('audio.flac')).toBe('audio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('3D files', () => {
|
||||
it('should identify 3D file extensions correctly', () => {
|
||||
expect(getMediaTypeFromFilename('model.obj')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty strings', () => {
|
||||
expect(getMediaTypeFromFilename('')).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle files without extensions', () => {
|
||||
expect(getMediaTypeFromFilename('README')).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle unknown extensions', () => {
|
||||
expect(getMediaTypeFromFilename('document.pdf')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('data.json')).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle files with multiple dots', () => {
|
||||
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle paths with directories', () => {
|
||||
expect(getMediaTypeFromFilename('/path/to/image.png')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('C:\\Windows\\video.mp4')).toBe('video')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMediaTypeFromFilenamePlural', () => {
|
||||
it('should return plural form for images', () => {
|
||||
expect(getMediaTypeFromFilenamePlural('test.png')).toBe('images')
|
||||
expect(getMediaTypeFromFilenamePlural('photo.jpg')).toBe('images')
|
||||
})
|
||||
|
||||
it('should return plural form for videos', () => {
|
||||
expect(getMediaTypeFromFilenamePlural('video.mp4')).toBe('videos')
|
||||
expect(getMediaTypeFromFilenamePlural('clip.webm')).toBe('videos')
|
||||
})
|
||||
|
||||
it('should return plural form for audios', () => {
|
||||
expect(getMediaTypeFromFilenamePlural('song.mp3')).toBe('audios')
|
||||
expect(getMediaTypeFromFilenamePlural('sound.wav')).toBe('audios')
|
||||
})
|
||||
|
||||
it('should return 3D as is (no plural)', () => {
|
||||
expect(getMediaTypeFromFilenamePlural('model.obj')).toBe('3D')
|
||||
expect(getMediaTypeFromFilenamePlural('scene.fbx')).toBe('3D')
|
||||
})
|
||||
|
||||
it('should default to images for unknown types', () => {
|
||||
expect(getMediaTypeFromFilenamePlural('document.pdf')).toBe('images')
|
||||
expect(getMediaTypeFromFilenamePlural('')).toBe('images')
|
||||
})
|
||||
})
|
||||
})
|
||||
149
tests-ui/tests/utils/uuidUtil.test.ts
Normal file
149
tests-ui/tests/utils/uuidUtil.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extractPromptIdFromAssetId,
|
||||
extractUuidFromString,
|
||||
isValidUuid
|
||||
} from '@/utils/uuidUtil'
|
||||
|
||||
describe('uuidUtil', () => {
|
||||
describe('extractUuidFromString', () => {
|
||||
it('should extract UUID from the beginning of a string', () => {
|
||||
const str = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-9-ComfyUI_00042_.png'
|
||||
const result = extractUuidFromString(str)
|
||||
expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3')
|
||||
})
|
||||
|
||||
it('should extract UUID with uppercase letters', () => {
|
||||
const str = 'A8B0B007-7D78-4E3F-B7A8-0F483B9CF2D3-node-file.png'
|
||||
const result = extractUuidFromString(str)
|
||||
expect(result).toBe('A8B0B007-7D78-4E3F-B7A8-0F483B9CF2D3')
|
||||
})
|
||||
|
||||
it('should return null if no UUID at the beginning', () => {
|
||||
const str = 'not-a-uuid-98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3'
|
||||
const result = extractUuidFromString(str)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid UUID format', () => {
|
||||
const str = '12345678-1234-1234-1234-123456789abc-extra'
|
||||
const result = extractUuidFromString(str)
|
||||
expect(result).toBe('12345678-1234-1234-1234-123456789abc')
|
||||
})
|
||||
|
||||
it('should handle UUID without trailing content', () => {
|
||||
const str = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3'
|
||||
const result = extractUuidFromString(str)
|
||||
expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3')
|
||||
})
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
const result = extractUuidFromString('')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for malformed UUID', () => {
|
||||
const str = '98b0b007-7d78-4e3f-b7a8'
|
||||
const result = extractUuidFromString(str)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidUuid', () => {
|
||||
it('should return true for valid UUID v4', () => {
|
||||
const uuid = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3'
|
||||
expect(isValidUuid(uuid)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for uppercase UUID', () => {
|
||||
const uuid = 'A8B0B007-7D78-4E3F-B7A8-0F483B9CF2D3'
|
||||
expect(isValidUuid(uuid)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for mixed case UUID', () => {
|
||||
const uuid = 'a8B0b007-7D78-4e3F-B7a8-0F483b9CF2d3'
|
||||
expect(isValidUuid(uuid)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for UUID with extra characters', () => {
|
||||
const uuid = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-extra'
|
||||
expect(isValidUuid(uuid)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for incomplete UUID', () => {
|
||||
const uuid = '98b0b007-7d78-4e3f-b7a8'
|
||||
expect(isValidUuid(uuid)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for UUID with wrong segment lengths', () => {
|
||||
const uuid = '98b0b0007-7d78-4e3f-b7a8-0f483b9cf2d3'
|
||||
expect(isValidUuid(uuid)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for UUID with invalid characters', () => {
|
||||
const uuid = '98b0b007-7d78-4e3f-b7a8-0f483b9cfzd3'
|
||||
expect(isValidUuid(uuid)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isValidUuid('')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for null-like values', () => {
|
||||
expect(isValidUuid('null')).toBe(false)
|
||||
expect(isValidUuid('undefined')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractPromptIdFromAssetId', () => {
|
||||
it('should extract promptId from typical asset ID', () => {
|
||||
const assetId =
|
||||
'98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-9-ComfyUI_00042_.png'
|
||||
const result = extractPromptIdFromAssetId(assetId)
|
||||
expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3')
|
||||
})
|
||||
|
||||
it('should extract promptId with multiple dashes in filename', () => {
|
||||
const assetId =
|
||||
'98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-15-my-image-file.png'
|
||||
const result = extractPromptIdFromAssetId(assetId)
|
||||
expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3')
|
||||
})
|
||||
|
||||
it('should handle asset ID with just UUID and node ID', () => {
|
||||
const assetId = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-42'
|
||||
const result = extractPromptIdFromAssetId(assetId)
|
||||
expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3')
|
||||
})
|
||||
|
||||
it('should return null for input folder asset ID', () => {
|
||||
const assetId = 'input-0-myimage.png'
|
||||
const result = extractPromptIdFromAssetId(assetId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for malformed asset ID', () => {
|
||||
const assetId = 'not-a-valid-asset-id'
|
||||
const result = extractPromptIdFromAssetId(assetId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle asset ID with special characters in filename', () => {
|
||||
const assetId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890-1-image(1)[2].png'
|
||||
const result = extractPromptIdFromAssetId(assetId)
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
|
||||
})
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
const result = extractPromptIdFromAssetId('')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle asset ID with underscores in UUID position', () => {
|
||||
const assetId = 'output_1_image.png'
|
||||
const result = extractPromptIdFromAssetId(assetId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user