Compare commits

...

13 Commits

Author SHA1 Message Date
Jin Yi
754eb9c3b4 feature: delete asset 2025-10-22 15:12:46 +09:00
Jin Yi
354c05ea68 chore: icon -> button markup 2025-10-22 13:29:31 +09:00
Jin Yi
1ee12833f4 feature: tab storybook added 2025-10-22 13:26:24 +09:00
Jin Yi
d064c489e4 refactor: Create reusable Tab components and update AssetsSidebarTab UI
- Add new Tab and TabList components with provide/inject pattern for
  state management
  - Replace TextButton-based tabs with new Tab components in
  AssetsSidebarTab
  - Update AssetSidebarTemplate to use semantic color tokens
  (bg-interface-panel-surface)
  - Improve tab styling with proper hover and focus states
2025-10-22 13:18:12 +09:00
Jin Yi
39dad98d87 refactor: Extract AssetsSidebarTab template and improve UI structure
- Extract sidebar template into reusable AssetSidebarTemplate component
  - Replace PrimeVue Tabs with TextButton for better visual consistency
  - Add i18n key for "Back to all assets" button
  - Improve job detail view header layout with better spacing
  - Maintain existing functionality while cleaning up template structure
2025-10-22 12:11:25 +09:00
Jin Yi
8682ca3e5c refactor: replace custom formatExecutionTime with formatDuration utility
- Use existing formatDuration util from shared utils instead of custom
  implementation
  - Extract execution time formatting to computed property for cleaner
  template
  - Remove 'any' type assertion in MediaAssetActions, use proper types
2025-10-22 12:11:25 +09:00
Jin Yi
72e130decf feat: add job folder view for grouped batch outputs in media assets
- Add folder view to display all outputs from a single batch job
  - Show output count badge on assets with multiple batch outputs
  - Add job ID display and copy functionality in folder view header
  - Display execution time for batch jobs
  - Implement download functionality for output assets only
  - Add inspect action to asset more menu
  - Extract prompt ID from asset IDs using new UUID utility
  - Add comprehensive tests for UUID extraction utilities
2025-10-22 12:11:25 +09:00
Jin Yi
bd23cccde3 fix: test code 2025-10-22 12:06:48 +09:00
Jin Yi
de8b6477b0 feat: Add includePublic parameter to getAssetsByTag API
- Add optional includePublic parameter (defaults to true) to getAssetsByTag
- Exclude public assets for media assets in sidebar by passing false
- Use URLSearchParams for cleaner query string construction

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:06:48 +09:00
Jin Yi
207fcc3ced feat: Improve media asset display with file format tags and filename truncation
- Add file format tags (PNG, JPG, etc.) for input directory assets
- Truncate long filenames in input assets with originalFilename preservation
- Show file format chip independently from duration chip
- Fix conditional display logic for chips in MediaAssetCard
- Apply consistent filename truncation (20 chars) across cloud assets

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:06:48 +09:00
Jin Yi
3776c3265c chore: unexpected export 2025-10-22 12:06:48 +09:00
Jin Yi
dcd6bb6519 refactor: Apply PR #6112 review feedback for Media Assets feature
- Move composables to platform/assets directory structure
- Extract interface-based abstraction (IAssetsProvider) for cloud/internal implementations
- Move constants to module scope to avoid re-initialization
- Extract helper functions (truncateFilename, assetMappers) for reusability
- Rename getMediaTypeFromFilename to return singular form (image/video/audio)
- Add deprecated plural version for backward compatibility
- Add comprehensive test coverage for new utility functions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:06:48 +09:00
Jin Yi
2398e26712 feat: Add Media Assets sidebar tab for file management
- Implement new sidebar tab for managing imported/generated files
- Add separate composables for internal and cloud environments
- Display execution time from history API on generated outputs
- Support gallery view with keyboard navigation
- Auto-truncate long filenames in cloud environment
- Add utility functions for media type detection
- Enable feature only in development mode

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:06:48 +09:00
30 changed files with 1884 additions and 198 deletions

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -474,3 +474,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)
}

View File

@@ -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',

View 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>

View 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>

View 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>

View 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>
`
})
}

View 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>

View File

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

View File

@@ -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.",

View File

@@ -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>

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetItem } from '../schemas/assetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
const meta: Meta<typeof MediaAssetCard> = {
@@ -28,10 +28,6 @@ const meta: Meta<typeof MediaAssetCard> = {
})
],
argTypes: {
context: {
control: 'select',
options: ['input', 'output']
},
loading: {
control: 'boolean'
}
@@ -53,19 +49,20 @@ const SAMPLE_MEDIA = {
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
}
const sampleAsset: AssetMeta = {
const sampleAsset: AssetItem = {
id: 'asset-1',
name: 'sample-image.png',
kind: 'image',
duration: 3345,
size: 2048576,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image1,
dimensions: {
width: 1920,
height: 1080
},
tags: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image1,
tags: ['input'],
user_metadata: {
duration: 3345,
dimensions: {
width: 1920,
height: 1080
}
}
}
export const ImageAsset: Story = {
@@ -75,7 +72,6 @@ export const ImageAsset: Story = {
})
],
args: {
context: { type: 'output', outputCount: 3 },
asset: sampleAsset,
loading: false
}
@@ -88,19 +84,18 @@ export const VideoAsset: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-2',
name: 'Big_Buck_Bunny.mp4',
kind: 'video',
size: 10485760,
duration: 13425,
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
src: SAMPLE_MEDIA.video, // Actual video file
dimensions: {
width: 1280,
height: 720
preview_url: SAMPLE_MEDIA.videoThumbnail,
user_metadata: {
duration: 13425,
dimensions: {
width: 1280,
height: 720
}
}
}
}
@@ -113,16 +108,15 @@ export const Model3DAsset: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-3',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
src: '',
dimensions: undefined,
duration: 18023
preview_url: '',
user_metadata: {
duration: 18023
}
}
}
}
@@ -134,16 +128,15 @@ export const AudioAsset: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-3',
id: 'asset-4',
name: 'SoundHelix-Song.mp3',
kind: 'audio',
size: 5242880,
src: SAMPLE_MEDIA.audio,
dimensions: undefined,
duration: 23180
preview_url: SAMPLE_MEDIA.audio,
user_metadata: {
duration: 23180
}
}
}
}
@@ -155,7 +148,6 @@ export const LoadingState: Story = {
})
],
args: {
context: { type: 'input' },
asset: sampleAsset,
loading: true
}
@@ -168,7 +160,6 @@ export const LongFileName: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png'
@@ -183,7 +174,6 @@ export const SelectedState: Story = {
})
],
args: {
context: { type: 'output', outputCount: 2 },
asset: sampleAsset,
selected: true
}
@@ -196,21 +186,20 @@ export const WebMVideo: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
id: 'asset-webm',
name: 'animated-clip.webm',
kind: 'video',
size: 3145728,
created_at: Date.now().toString(),
preview_url: SAMPLE_MEDIA.image1, // Poster image
src: 'https://www.w3schools.com/html/movie.mp4', // Actual video
duration: 620,
dimensions: {
width: 640,
height: 360
},
tags: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image1,
tags: ['input'],
user_metadata: {
duration: 620,
dimensions: {
width: 640,
height: 360
}
}
}
}
}
@@ -222,20 +211,20 @@ export const GifAnimation: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
id: 'asset-gif',
name: 'animation.gif',
kind: 'image',
size: 1572864,
duration: 1345,
created_at: Date.now().toString(),
src: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
dimensions: {
width: 480,
height: 270
},
tags: []
created_at: new Date().toISOString(),
preview_url: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
tags: ['input'],
user_metadata: {
duration: 1345,
dimensions: {
width: 480,
height: 270
}
}
}
}
}
@@ -244,83 +233,89 @@ export const GridLayout: Story = {
render: () => ({
components: { MediaAssetCard },
setup() {
const assets: AssetMeta[] = [
const assets: AssetItem[] = [
{
id: 'grid-1',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image1,
dimensions: { width: 1920, height: 1080 },
tags: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image1,
tags: ['input'],
user_metadata: {
duration: 4500,
dimensions: { width: 1920, height: 1080 }
}
},
{
id: 'grid-2',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image2,
dimensions: { width: 1920, height: 1080 },
tags: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image2,
tags: ['input'],
user_metadata: {
duration: 4500,
dimensions: { width: 1920, height: 1080 }
}
},
{
id: 'grid-3',
name: 'video-file.mp4',
kind: 'video',
size: 10485760,
duration: 13425,
created_at: Date.now().toString(),
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
src: SAMPLE_MEDIA.video, // Actual video
dimensions: { width: 1280, height: 720 },
tags: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.videoThumbnail,
tags: ['input'],
user_metadata: {
duration: 13425,
dimensions: { width: 1280, height: 720 }
}
},
{
id: 'grid-4',
name: 'audio-file.mp3',
kind: 'audio',
size: 5242880,
duration: 180,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.audio,
tags: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.audio,
tags: ['input'],
user_metadata: {
duration: 180
}
},
{
id: 'grid-5',
name: 'animation.gif',
kind: 'image',
size: 3145728,
duration: 1345,
created_at: Date.now().toString(),
src: 'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
dimensions: { width: 480, height: 360 },
tags: []
created_at: new Date().toISOString(),
preview_url:
'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
tags: ['input'],
user_metadata: {
duration: 1345,
dimensions: { width: 480, height: 360 }
}
},
{
id: 'grid-6',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
src: '',
dimensions: undefined,
duration: 18023,
created_at: Date.now().toString(),
tags: []
preview_url: '',
created_at: new Date().toISOString(),
tags: ['input'],
user_metadata: {
duration: 18023
}
},
{
id: 'grid-7',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image3,
dimensions: { width: 1920, height: 1080 },
tags: []
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image3,
tags: ['input'],
user_metadata: {
duration: 4500,
dimensions: { width: 1920, height: 1080 }
}
}
]
return { assets }
@@ -330,7 +325,6 @@ export const GridLayout: Story = {
<MediaAssetCard
v-for="asset in assets"
:key="asset.id"
:context="{ type: Math.random() > 0.5 ? 'input' : 'output', outputCount: Math.floor(Math.random() * 5) }"
:asset="asset"
/>
</div>

View File

@@ -2,9 +2,7 @@
<CardContainer
ref="cardContainerRef"
role="button"
:aria-label="
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
"
:aria-label="asset ? `${asset.name} - ${fileKind} asset` : 'Loading asset'"
:tabindex="loading ? -1 : 0"
size="mini"
variant="ghost"
@@ -28,16 +26,17 @@
</template>
<!-- Content based on asset type -->
<template v-else-if="asset">
<template v-else-if="asset && adaptedAsset">
<component
:is="getTopComponent(asset.kind)"
:asset="asset"
:context="context"
:is="getTopComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
@view="handleZoomClick"
@download="actions.downloadAsset(asset!.id)"
@play="actions.playAsset(asset!.id)"
@download="actions.downloadAsset()"
@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>

View File

@@ -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>

View File

@@ -7,7 +7,9 @@
{{ fileName }}
</h3>
<div class="flex items-center text-xs text-zinc-400">
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
<span v-if="asset.dimensions"
>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span
>
</div>
</div>
</template>

View File

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

View File

@@ -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'

View File

@@ -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,

View File

@@ -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[]>
}

View File

@@ -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}`
)
}
}

View 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()
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

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

41
src/utils/uuidUtil.ts Normal file
View 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)
}

View File

@@ -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)
})
})
})

View 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')
})
})
})

View 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()
})
})
})