Media Assets Management Sidebar Tab Implementation (#6112)

## 📋 Overview
Implemented a new Media Assets sidebar tab in ComfyUI for managing
user-uploaded input files and generated output files. This feature
supports both local and cloud environments and is currently enabled only
in development mode.

## 🎯 Key Features

### 1. Media Assets Sidebar Tab
- **Imported** / **Generated** files separated by tabs
- Visual display with file preview cards
- Gallery view support (navigable with arrow keys)

### 2. Environment-Specific Implementation
- **`useInternalMediaAssets`**: For local environment
  - Fetches file list via `/files` API
  - Retrieves generation task execution time via `/history` API
  - Processes history data using the same logic as QueueSidebarTab
- **`useCloudMediaAssets`**: For cloud environment
  - File retrieval through assetService
  - History data processing using TaskItemImpl
- Auto-truncation of long filenames over 20 characters (e.g.,
`very_long_filename_here.png` → `very_long_...here.png`)

### 3. Execution Time Display
- Shows task execution time on generated image cards (e.g., "2.3s")
- Calculated from History API's `execution_start` and
`execution_success` messages
- Displayed at MediaAssetCard's duration chip location

### 4. Gallery Feature
- Full-screen gallery mode on image click
- Navigate between images with keyboard arrows
- Exit gallery with ESC key
- Reuses ResultGallery component from QueueSidebarTab

### 5. Development Mode Only
- Excluded from production builds using `import.meta.env.DEV` condition
- Feature in development, scheduled for official release after
stabilization

## 🛠️ Technical Changes

### New Files Added
- `src/components/sidebar/tabs/AssetsSidebarTab.vue` - Main sidebar tab
component
- `src/composables/sidebarTabs/useAssetsSidebarTab.ts` - Sidebar tab
definition
- `src/composables/useInternalMediaAssets.ts` - Local environment
implementation
- `src/composables/useCloudMediaAssets.ts` - Cloud environment
implementation
- `packages/design-system/src/icons/image-ai-edit.svg` - Icon addition

### Modified Files
- `src/stores/workspace/sidebarTabStore.ts` - Added dev mode only tab
display logic
- `src/platform/assets/components/MediaAssetCard.vue` - Added execution
time display, zoom event
- `src/platform/assets/components/MediaImageTop.vue` - Added image
dimension detection
- `packages/shared-frontend-utils/src/formatUtil.ts` - Added media type
determination utility functions
- `src/locales/en/main.json` - Added translation keys


[media_asset_OSS_cloud.webm](https://github.com/user-attachments/assets/a6ee3b49-19ed-4735-baad-c2ac2da868ef)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Jin Yi
2025-10-29 12:39:16 +09:00
committed by GitHub
parent 5f3b8fb8c8
commit 06ba106f59
60 changed files with 1797 additions and 229 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

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,68 @@ export function formatDuration(milliseconds: number): string {
return parts.join(' ')
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const
const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const
type MediaType = (typeof MEDIA_TYPES)[number]
// Type guard helper for checking array membership
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
/**
* 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): MediaType {
if (!filename) return 'image'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'
// Type-safe array includes check using type assertion
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
return 'image'
}

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,297 @@
<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-2">
<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="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
/>
</template>
</VirtualGrid>
<div v-else-if="loading">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</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, 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 { t } from '@/i18n'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
const activeTab = ref<'input' | 'output'>('input')
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 getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 0
}
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
}
return getOutputCount(item) > 1
}
const formattedExecutionTime = computed(() => {
if (!folderExecutionTime.value) return ''
return formatDuration(folderExecutionTime.value * 1000)
})
const toast = useToast()
const inputAssets = useMediaAssets('input')
const outputAssets = useMediaAssets('output')
const currentAssets = computed(() =>
activeTab.value === 'input' ? inputAssets : outputAssets
)
const loading = computed(() => currentAssets.value.loading.value)
const error = computed(() => currentAssets.value.error.value)
const mediaAssets = computed(() => currentAssets.value.media.value)
const galleryActiveIndex = ref(-1)
const currentGalleryAssetId = ref<string | null>(null)
const folderAssets = ref<AssetItem[]>([])
const displayAssets = computed(() => {
if (isInFolderView.value) {
return folderAssets.value
}
return mediaAssets.value
})
watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
(asset) => asset.id === currentGalleryAssetId.value
)
if (newIndex !== -1) {
galleryActiveIndex.value = newIndex
}
}
})
watch(galleryActiveIndex, (index) => {
if (index === -1) {
currentGalleryAssetId.value = null
}
})
const galleryItems = computed(() => {
return displayAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: mediaType === 'image' ? 'images' : mediaType
})
Object.defineProperty(resultItem, 'url', {
get() {
return asset.preview_url || ''
},
configurable: true
})
return resultItem
})
})
// Add key property for VirtualGrid
const mediaAssetsWithKey = computed(() => {
return displayAssets.value.map((asset) => ({
...asset,
key: asset.id
}))
})
const refreshAssets = async () => {
await currentAssets.value.fetchMediaList()
if (error.value) {
console.error('Failed to refresh assets:', error.value)
}
}
watch(
activeTab,
() => {
void refreshAssets()
},
{ immediate: true }
)
const handleAssetSelect = (asset: AssetItem) => {
if (selectedAsset.value?.id === asset.id) {
selectedAsset.value = null
} else {
selectedAsset.value = asset
}
}
const handleZoomClick = (asset: AssetItem) => {
currentGalleryAssetId.value = asset.id
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) {
galleryActiveIndex.value = index
}
}
const enterFolderView = (asset: AssetItem) => {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
console.warn('Invalid output asset metadata')
return
}
const { promptId, allOutputs, executionTimeInSeconds } = metadata
if (!promptId || !Array.isArray(allOutputs) || allOutputs.length === 0) {
console.warn('Missing required folder view data')
return
}
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
folderAssets.value = allOutputs.map((output) => ({
id: `${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
created_at: asset.created_at,
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds,
workflow: metadata.workflow
}
}))
}
const exitFolderView = () => {
folderPromptId.value = null
folderExecutionTime.value = undefined
folderAssets.value = []
}
const copyJobId = async () => {
if (folderPromptId.value) {
try {
await navigator.clipboard.writeText(folderPromptId.value)
toast.add({
severity: 'success',
summary: t('mediaAsset.jobIdToast.copied'),
detail: t('mediaAsset.jobIdToast.jobIdCopied'),
life: 2000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('mediaAsset.jobIdToast.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
})
}
}
}
</script>

View File

@@ -0,0 +1,48 @@
<template>
<button
:id="tabId"
:class="tabClasses"
role="tab"
:aria-selected="isActive"
:aria-controls="panelId"
:tabindex="0"
@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, panelId } = defineProps<{
value: string
panelId?: string
}>()
const currentValue = inject<Ref<string>>('tabs-value')
const updateValue = inject<(value: string) => void>('tabs-update')
const tabId = computed(() => `tab-${value}`)
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,17 @@
<template>
<div role="tablist" class="flex w-full items-center gap-2 pb-1">
<slot />
</div>
</template>
<script setup lang="ts">
import { provide } from 'vue'
const modelValue = defineModel<string>({ required: true })
// Provide for child Tab components
provide('tabs-value', modelValue)
provide('tabs-update', (value: string) => {
modelValue.value = value
})
</script>

View File

@@ -5,6 +5,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
const PASTED_IMAGE_EXPIRY_MS = 2000
@@ -37,6 +38,13 @@ const uploadFile = async (
}
const data = await resp.json()
// Update AssetsStore input assets when files are uploaded to input folder
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
const assetsStore = useAssetsStore()
await assetsStore.updateInputs()
}
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}

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

@@ -599,6 +599,9 @@
"nodeLibrary": "Node Library",
"workflows": "Workflows",
"templates": "Templates",
"assets": "Assets",
"mediaAssets": "Media Assets",
"backToAssets": "Back to all assets",
"labels": {
"queue": "Queue",
"nodes": "Nodes",
@@ -606,8 +609,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",
@@ -1760,7 +1770,24 @@
"sortAZ": "A-Z",
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular"
"sortPopular": "Popular",
"ariaLabel": {
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
}
},
"mediaAsset": {
"deleteAssetTitle": "Delete this asset?",
"deleteAssetDescription": "This asset will be permanently removed.",
"assetDeletedSuccessfully": "Asset deleted successfully",
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
"failedToDeleteAsset": "Failed to delete asset",
"jobIdToast": {
"jobIdCopied": "Job ID copied to clipboard",
"jobIdCopyFailed": "Failed to copy Job ID",
"copied": "Copied",
"error": "Error"
}
},
"actionbar": {
"dockToTop": "Dock to top"

View File

@@ -1,11 +1,6 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<MediaTitle :file-name="fileName" />
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
@@ -18,6 +13,7 @@ import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<div class="relative size-full overflow-hidden rounded">
<div
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex size-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i
class="icon-[lucide--box] text-3xl text-zinc-600 dark-theme:text-zinc-200"

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,18 +12,23 @@
@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 IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import { isCloud } from '@/platform/distribution/types'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
@@ -31,20 +36,35 @@ import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
const emit = defineEmits<{
menuStateChanged: [isOpen: boolean]
inspect: []
'asset-deleted': []
}>()
const { asset } = inject(MediaAssetKey)!
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
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 = async () => {
if (!asset.value) return
const success = await actions.confirmDelete(asset.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

@@ -3,7 +3,12 @@
ref="cardContainerRef"
role="button"
:aria-label="
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.name,
type: fileKind
})
: $t('assetBrowser.ariaLabel.loadingAsset')
"
:tabindex="loading ? -1 : 0"
size="mini"
@@ -28,16 +33,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 +51,8 @@
<template v-if="showActionsOverlay" #top-left>
<MediaAssetActions
@menu-state-changed="isMenuOpen = $event"
@inspect="handleZoomClick"
@asset-deleted="handleAssetDelete"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
/>
@@ -63,13 +71,17 @@
</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>
@@ -79,8 +91,8 @@
<IconTextButton
type="secondary"
size="sm"
:label="context?.outputCount?.toString() ?? '0'"
@click.stop="actions.openMoreOutputs(asset?.id || '')"
:label="String(outputCount)"
@click.stop="handleOutputCountClick"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
@@ -107,11 +119,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 +141,13 @@ 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 { getAssetType } from '../composables/media/assetMappers'
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 +174,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 +195,44 @@ 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
const assetType = computed(() => {
return getAssetType(asset?.tags)
})
// 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
}
})
provide(MediaAssetKey, {
asset: toRef(() => asset),
context: toRef(() => context),
asset: toRef(() => adaptedAsset.value),
context: toRef(() => ({ type: assetType.value })),
isVideoPlaying,
showVideoControls
})
@@ -201,8 +247,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 +266,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 +292,29 @@ 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)
)
const handleCardClick = () => {
if (asset) {
actions.selectAsset(asset)
if (adaptedAsset.value) {
actions.selectAsset(adaptedAsset.value)
}
}
@@ -272,7 +328,19 @@ const handleOverlayMouseLeave = () => {
const handleZoomClick = () => {
if (asset) {
galleryStore.openSingle(asset)
emit('zoom', asset)
}
}
const handleImageLoaded = (width: number, height: number) => {
imageDimensions.value = { width, height }
}
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"
@@ -92,9 +95,9 @@
import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { isCloud } from '@/platform/distribution/types'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
@@ -102,16 +105,36 @@ const { close } = defineProps<{
close: () => void
}>()
const emit = defineEmits<{
inspect: []
'asset-deleted': []
}>()
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
const showWorkflowOptions = computed(() => context.value.type)
const assetType = computed(() => {
return asset.value?.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 +147,7 @@ const handleAddToWorkflow = () => {
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset(asset.value.id)
actions.downloadAsset()
}
close()
}
@@ -143,17 +166,21 @@ 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)
const handleDelete = async () => {
if (!asset.value) return
close() // Close the menu first
const success = await actions.confirmDelete(asset.value)
if (success) {
emit('asset-deleted')
}
close()
}
</script>

View File

@@ -1,11 +1,6 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<MediaTitle :file-name="fileName" />
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
@@ -18,6 +13,7 @@ import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<div class="relative size-full overflow-hidden rounded">
<div
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex size-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i
class="icon-[lucide--music] text-3xl text-zinc-600 dark-theme:text-zinc-200"

View File

@@ -1,13 +1,10 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<MediaTitle :file-name="fileName" />
<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>
@@ -18,6 +15,7 @@ import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,15 +1,16 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<LazyImage
v-if="asset.src"
<div
class="relative size-full overflow-hidden rounded bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<img
v-if="!error"
:src="asset.src"
:alt="asset.name"
:container-class="'aspect-square'"
:image-class="'w-full h-full object-cover'"
class="size-full object-contain"
/>
<div
v-else
class="flex h-full w-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex size-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i class="pi pi-image text-3xl text-smoke-400" />
</div>
@@ -17,11 +18,27 @@
</template>
<script setup lang="ts">
import LazyImage from '@/components/common/LazyImage.vue'
import { useImage, whenever } from '@vueuse/core'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const emit = defineEmits<{
'image-loaded': [width: number, height: number]
}>()
const { state, error, isReady } = useImage({
src: asset.src ?? '',
alt: asset.name
})
whenever(
() =>
isReady.value && state.value?.naturalWidth && state.value?.naturalHeight,
() =>
emit('image-loaded', state.value!.naturalWidth, state.value!.naturalHeight)
)
</script>

View File

@@ -0,0 +1,21 @@
<template>
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="fullName"
>
{{ displayName }}
</h3>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { truncateFilename } from '@/utils/formatUtil'
const props = defineProps<{
fileName: string
}>()
const fullName = computed(() => props.fileName)
const displayName = computed(() => truncateFilename(props.fileName))
</script>

View File

@@ -1,13 +1,8 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<MediaTitle :file-name="fileName" />
<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,9 +10,10 @@
<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'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,6 +1,6 @@
<template>
<div
class="relative h-full w-full overflow-hidden rounded bg-black"
class="relative size-full overflow-hidden rounded bg-black"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
@@ -9,7 +9,7 @@
:controls="shouldShowControls"
preload="none"
:poster="asset.preview_url"
class="relative h-full w-full object-contain"
class="relative size-full object-contain"
@click.stop
@play="onVideoPlay"
@pause="onVideoPause"

View File

@@ -0,0 +1,29 @@
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 {
/** Current media assets */
media: Ref<AssetItem[]>
/** Loading state indicator */
loading: Ref<boolean>
/** Error state */
error: Ref<unknown>
/**
* Fetch list of media assets
* @returns Promise resolving to array of AssetItem
*/
fetchMediaList: () => Promise<AssetItem[]>
/**
* Refresh the media list (alias for fetchMediaList)
*/
refresh: () => Promise<AssetItem[]>
}

View File

@@ -0,0 +1,73 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema'
import { api } from '@/scripts/api'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
/**
* Extract asset type from tags array
* @param tags The tags array from AssetItem
* @returns The asset type ('input' or 'output')
*/
export function getAssetType(tags?: string[]): AssetContext['type'] {
const tag = tags?.[0]
if (tag === 'output') return 'output'
return 'input'
}
/**
* 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: ResultItemImpl
): AssetItem {
const metadata: OutputAssetMetadata = {
promptId: taskItem.promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds: taskItem.executionTimeInSeconds,
format: output.format,
workflow: taskItem.workflow
}
return {
id: taskItem.promptId,
name: output.filename,
size: 0,
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,46 @@
import { computed } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
/**
* Composable for fetching media assets from cloud environment
* Uses AssetsStore for centralized state management
*/
export function useAssetsApi(directory: 'input' | 'output') {
const assetsStore = useAssetsStore()
const media = computed(() =>
directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
)
const loading = computed(() =>
directory === 'input'
? assetsStore.inputLoading
: assetsStore.historyLoading
)
const error = computed(() =>
directory === 'input' ? assetsStore.inputError : assetsStore.historyError
)
const fetchMediaList = async (): Promise<AssetItem[]> => {
if (directory === 'input') {
await assetsStore.updateInputs()
return assetsStore.inputAssets
} else {
await assetsStore.updateHistory()
return assetsStore.historyAssets
}
}
const refresh = () => fetchMediaList()
return {
media,
loading,
error,
fetchMediaList,
refresh
}
}

View File

@@ -0,0 +1,46 @@
import { computed } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
/**
* Composable for fetching media assets from local environment
* Uses AssetsStore for centralized state management
*/
export function useInternalFilesApi(directory: 'input' | 'output') {
const assetsStore = useAssetsStore()
const media = computed(() =>
directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
)
const loading = computed(() =>
directory === 'input'
? assetsStore.inputLoading
: assetsStore.historyLoading
)
const error = computed(() =>
directory === 'input' ? assetsStore.inputError : assetsStore.historyError
)
const fetchMediaList = async (): Promise<AssetItem[]> => {
if (directory === 'input') {
await assetsStore.updateInputs()
return assetsStore.inputAssets
} else {
await assetsStore.updateHistory()
return assetsStore.historyAssets
}
}
const refresh = () => fetchMediaList()
return {
media,
loading,
error,
fetchMediaList,
refresh
}
}

View File

@@ -0,0 +1,15 @@
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)
* @param directory - The directory to fetch assets from
* @returns IAssetsProvider implementation
*/
export function useMediaAssets(directory: 'input' | 'output'): IAssetsProvider {
return isCloud ? useAssetsApi(directory) : useInternalFilesApi(directory)
}

View File

@@ -1,29 +1,191 @@
/* eslint-disable no-console */
import { useToast } from 'primevue/usetoast'
import { inject } from 'vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import { downloadFile } from '@/base/common/downloadUtil'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
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 dialogStore = useDialogStore()
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.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)
/**
* Show confirmation dialog and delete asset if confirmed
* @param asset The asset to delete
* @returns true if the asset was deleted, false otherwise
*/
const confirmDelete = async (asset: AssetItem): Promise<boolean> => {
const assetType = asset.tags?.[0] || 'output'
return new Promise((resolve) => {
dialogStore.showDialog({
key: 'delete-asset-confirmation',
title: t('mediaAsset.deleteAssetTitle'),
component: ConfirmationDialogContent,
props: {
message: t('mediaAsset.deleteAssetDescription'),
type: 'delete',
itemList: [asset.name],
onConfirm: async () => {
const success = await deleteAsset(asset, assetType)
resolve(success)
},
onCancel: () => {
resolve(false)
}
}
})
})
}
const deleteAsset = async (asset: AssetItem, assetType: string) => {
const assetsStore = useAssetsStore()
try {
if (assetType === 'output') {
// For output files, delete from history
const promptId =
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
if (!promptId) {
throw new Error('Unable to extract prompt ID from asset')
}
await api.deleteItem('history', promptId)
// Update history assets in store after deletion
await assetsStore.updateHistory()
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.assetDeletedSuccessfully'),
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: t('mediaAsset.deletingImportedFilesCloudOnly'),
life: 3000
})
return false
}
// In cloud environment, use the assets API to delete
await assetService.deleteAsset(asset.id)
// Update input assets in store after deletion
await assetsStore.updateInputs()
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.assetDeletedSuccessfully'),
life: 2000
})
return true
}
} catch (error) {
console.error('Failed to delete asset:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail:
error instanceof Error
? error.message
: t('mediaAsset.failedToDeleteAsset'),
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)
// Get promptId from metadata instead of parsing the ID string
const metadata = getOutputAssetMetadata(asset.user_metadata)
const promptId = metadata?.promptId
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: t('mediaAsset.jobIdToast.jobIdCopied'),
life: 2000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
})
}
}
const addWorkflow = (assetId: string) => {
@@ -45,9 +207,9 @@ export function useMediaAssetActions() {
return {
selectAsset,
downloadAsset,
confirmDelete,
deleteAsset,
playAsset,
copyAssetUrl,
copyJobId,
addWorkflow,
openWorkflow,

View File

@@ -0,0 +1,41 @@
import type { ResultItemImpl } from '@/stores/queueStore'
/**
* Metadata for output assets from queue store
* Extends Record<string, unknown> for compatibility with AssetItem schema
*/
export interface OutputAssetMetadata extends Record<string, unknown> {
promptId: string
nodeId: string | number
subfolder: string
executionTimeInSeconds?: number
format?: string
workflow?: unknown
outputCount?: number
allOutputs?: ResultItemImpl[]
}
/**
* Type guard to check if metadata is OutputAssetMetadata
*/
function isOutputAssetMetadata(
metadata: Record<string, unknown> | undefined
): metadata is OutputAssetMetadata {
if (!metadata) return false
return (
typeof metadata.promptId === 'string' &&
(typeof metadata.nodeId === 'string' || typeof metadata.nodeId === 'number')
)
}
/**
* Safely extract output asset metadata
*/
export function getOutputAssetMetadata(
userMetadata: Record<string, unknown> | undefined
): OutputAssetMetadata | null {
if (isOutputAssetMetadata(userMetadata)) {
return userMetadata
}
return null
}

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

@@ -195,11 +195,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}`
)
@@ -208,13 +218,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

@@ -7,6 +7,7 @@ import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useQueueStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
@@ -223,6 +224,13 @@ const uploadFile = async (
}
const data = await resp.json()
// Update AssetsStore when uploading to input folder
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
const assetsStore = useAssetsStore()
await assetsStore.updateInputs()
}
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}

133
src/stores/assetsStore.ts Normal file
View File

@@ -0,0 +1,133 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import {
mapInputFileToAssetItem,
mapTaskOutputToAssetItem
} from '@/platform/assets/composables/media/assetMappers'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { TaskItemImpl } from './queueStore'
/**
* Fetch input files from the internal API (OSS version)
*/
async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
const response = await fetch(api.internalURL('/files/input'), {
headers: {
'Comfy-User': api.user
}
})
if (!response.ok) {
throw new Error('Failed to fetch input files')
}
const filenames: string[] = await response.json()
return filenames.map((name, index) =>
mapInputFileToAssetItem(name, index, 'input')
)
}
/**
* Fetch input files from cloud service
*/
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
return await assetService.getAssetsByTag('input', false)
}
/**
* Convert history task items to asset items
*/
function mapHistoryToAssets(historyItems: any[]): AssetItem[] {
const assetItems: AssetItem[] = []
for (const item of historyItems) {
if (!item.outputs || !item.status || item.status?.status_str === 'error') {
continue
}
const task = new TaskItemImpl(
'History',
item.prompt,
item.status,
item.outputs
)
if (!task.previewOutput) {
continue
}
const assetItem = mapTaskOutputToAssetItem(task, task.previewOutput)
const supportedOutputs = task.flatOutputs.filter((o) => o.supportsPreview)
assetItem.user_metadata = {
...assetItem.user_metadata,
outputCount: supportedOutputs.length,
allOutputs: supportedOutputs
}
assetItems.push(assetItem)
}
return assetItems.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
}
export const useAssetsStore = defineStore('assets', () => {
const maxHistoryItems = 200
const fetchInputFiles = isCloud
? fetchInputFilesFromCloud
: fetchInputFilesFromAPI
const {
state: inputAssets,
isLoading: inputLoading,
error: inputError,
execute: updateInputs
} = useAsyncState(fetchInputFiles, [], {
immediate: false,
resetOnExecute: false,
onError: (err) => {
console.error('Error fetching input assets:', err)
}
})
const fetchHistoryAssets = async (): Promise<AssetItem[]> => {
const history = await api.getHistory(maxHistoryItems)
return mapHistoryToAssets(history.History)
}
const {
state: historyAssets,
isLoading: historyLoading,
error: historyError,
execute: updateHistory
} = useAsyncState(fetchHistoryAssets, [], {
immediate: false,
resetOnExecute: false,
onError: (err) => {
console.error('Error fetching history assets:', err)
}
})
return {
// States
inputAssets,
historyAssets,
inputLoading,
historyLoading,
inputError,
historyError,
// Actions
updateInputs,
updateHistory
}
})

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

View File

@@ -54,6 +54,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { setupAutoQueueHandler } from '@/services/autoQueueService'
import { useKeybindingService } from '@/services/keybindingService'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
@@ -80,6 +81,7 @@ const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const colorPaletteStore = useColorPaletteStore()
const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
@@ -188,11 +190,17 @@ const init = () => {
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
queuePendingTaskCountStore.update(e)
await queueStore.update()
await Promise.all([
queueStore.update(),
assetsStore.updateHistory() // Update history assets when status changes
])
}
const onExecutionSuccess = async () => {
await queueStore.update()
await Promise.all([
queueStore.update(),
assetsStore.updateHistory() // Update history assets on execution success
])
}
const reconnectingMessage: ToastMessageOptions = {

View File

@@ -294,7 +294,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)
})
@@ -352,5 +352,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,145 @@
import { describe, expect, it } from 'vitest'
import { getMediaTypeFromFilename, 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', () => {
const imageTestCases = [
{ filename: 'test.png', expected: 'image' },
{ filename: 'photo.jpg', expected: 'image' },
{ filename: 'image.jpeg', expected: 'image' },
{ filename: 'animation.gif', expected: 'image' },
{ filename: 'web.webp', expected: 'image' },
{ filename: 'bitmap.bmp', expected: 'image' }
]
it.for(imageTestCases)(
'should identify $filename as $expected',
({ filename, expected }) => {
expect(getMediaTypeFromFilename(filename)).toBe(expected)
}
)
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')
})
it('should handle null and undefined gracefully', () => {
expect(getMediaTypeFromFilename(null as any)).toBe('image')
expect(getMediaTypeFromFilename(undefined as any)).toBe('image')
})
it('should handle special characters in filenames', () => {
expect(getMediaTypeFromFilename('test@#$.png')).toBe('image')
expect(getMediaTypeFromFilename('video (1).mp4')).toBe('video')
expect(getMediaTypeFromFilename('[2024] audio.mp3')).toBe('audio')
})
it('should handle very long filenames', () => {
const longFilename = 'a'.repeat(1000) + '.png'
expect(getMediaTypeFromFilename(longFilename)).toBe('image')
})
it('should handle mixed case extensions', () => {
expect(getMediaTypeFromFilename('test.PnG')).toBe('image')
expect(getMediaTypeFromFilename('video.Mp4')).toBe('video')
expect(getMediaTypeFromFilename('audio.WaV')).toBe('audio')
})
})
})
})