mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 02:32:18 +00:00
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:
29
src/platform/assets/composables/media/IAssetsProvider.ts
Normal file
29
src/platform/assets/composables/media/IAssetsProvider.ts
Normal 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[]>
|
||||
}
|
||||
73
src/platform/assets/composables/media/assetMappers.ts
Normal file
73
src/platform/assets/composables/media/assetMappers.ts
Normal 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}`
|
||||
)
|
||||
}
|
||||
}
|
||||
46
src/platform/assets/composables/media/useAssetsApi.ts
Normal file
46
src/platform/assets/composables/media/useAssetsApi.ts
Normal 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
|
||||
}
|
||||
}
|
||||
46
src/platform/assets/composables/media/useInternalFilesApi.ts
Normal file
46
src/platform/assets/composables/media/useInternalFilesApi.ts
Normal 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
|
||||
}
|
||||
}
|
||||
15
src/platform/assets/composables/media/useMediaAssets.ts
Normal file
15
src/platform/assets/composables/media/useMediaAssets.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user