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

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,