From 2bb54650b48de2db91e4178ce91c6cf881929c03 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Sat, 18 Oct 2025 00:05:54 +0900 Subject: [PATCH] feat: Add Media Assets sidebar tab for file management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement new sidebar tab for managing imported/generated files - Add separate composables for internal and cloud environments - Display execution time from history API on generated outputs - Support gallery view with keyboard navigation - Auto-truncate long filenames in cloud environment - Add utility functions for media type detection - Enable feature only in development mode 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../design-system/src/icons/image-ai-edit.svg | 5 + .../shared-frontend-utils/src/formatUtil.ts | 48 +++++ .../sidebar/tabs/AssetsSidebarTab.vue | 150 +++++++++++++ .../sidebarTabs/useAssetsSidebarTab.ts | 16 ++ src/composables/useCloudMediaAssets.ts | 133 ++++++++++++ src/composables/useInternalMediaAssets.ts | 129 +++++++++++ src/locales/en/main.json | 13 +- .../components/MediaAssetCard.stories.ts | 200 +++++++++--------- .../assets/components/MediaAssetCard.vue | 133 ++++++++---- .../assets/components/MediaImageBottom.vue | 4 +- .../assets/components/MediaImageTop.vue | 37 +++- src/stores/workspace/sidebarTabStore.ts | 9 +- 12 files changed, 720 insertions(+), 157 deletions(-) create mode 100644 packages/design-system/src/icons/image-ai-edit.svg create mode 100644 src/components/sidebar/tabs/AssetsSidebarTab.vue create mode 100644 src/composables/sidebarTabs/useAssetsSidebarTab.ts create mode 100644 src/composables/useCloudMediaAssets.ts create mode 100644 src/composables/useInternalMediaAssets.ts diff --git a/packages/design-system/src/icons/image-ai-edit.svg b/packages/design-system/src/icons/image-ai-edit.svg new file mode 100644 index 000000000..437669d6d --- /dev/null +++ b/packages/design-system/src/icons/image-ai-edit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index 658498f1f..b9859e3da 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -474,3 +474,51 @@ export function formatDuration(milliseconds: number): string { return parts.join(' ') } + +/** + * Determines the media type from a filename's extension + * @param filename The filename to analyze + * @returns The media type: 'images', 'videos', 'audios', '3D' for gallery compatibility + */ +export function getMediaTypeFromFilename(filename: string): string { + if (!filename) return 'images' + const ext = filename.split('.').pop()?.toLowerCase() + if (!ext) return 'images' + + const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] + const videoExts = ['mp4', 'webm', 'mov', 'avi'] + const audioExts = ['mp3', 'wav', 'ogg', 'flac'] + const threeDExts = ['obj', 'fbx', 'gltf', 'glb'] + + if (imageExts.includes(ext)) return 'images' + if (videoExts.includes(ext)) return 'videos' + if (audioExts.includes(ext)) return 'audios' + if (threeDExts.includes(ext)) return '3D' + + return 'images' +} + +/** + * Determines the media kind from a filename's extension + * @param filename The filename to analyze + * @returns The media kind: 'image', 'video', 'audio', or '3D' + */ +export function getMediaKindFromFilename( + filename: string +): 'image' | 'video' | 'audio' | '3D' { + if (!filename) return 'image' + const ext = filename.split('.').pop()?.toLowerCase() + if (!ext) return 'image' + + const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] + const videoExts = ['mp4', 'webm', 'mov', 'avi'] + const audioExts = ['mp3', 'wav', 'ogg', 'flac'] + const threeDExts = ['obj', 'fbx', 'gltf', 'glb'] + + if (imageExts.includes(ext)) return 'image' + if (videoExts.includes(ext)) return 'video' + if (audioExts.includes(ext)) return 'audio' + if (threeDExts.includes(ext)) return '3D' + + return 'image' +} diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue new file mode 100644 index 000000000..aaceaa428 --- /dev/null +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -0,0 +1,150 @@ + + + diff --git a/src/composables/sidebarTabs/useAssetsSidebarTab.ts b/src/composables/sidebarTabs/useAssetsSidebarTab.ts new file mode 100644 index 000000000..ce3753173 --- /dev/null +++ b/src/composables/sidebarTabs/useAssetsSidebarTab.ts @@ -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' + } +} diff --git a/src/composables/useCloudMediaAssets.ts b/src/composables/useCloudMediaAssets.ts new file mode 100644 index 000000000..ff79933ff --- /dev/null +++ b/src/composables/useCloudMediaAssets.ts @@ -0,0 +1,133 @@ +import { ref } from 'vue' + +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import { assetService } from '@/platform/assets/services/assetService' +import type { HistoryTaskItem } from '@/schemas/apiSchema' +import { api } from '@/scripts/api' +import { TaskItemImpl } from '@/stores/queueStore' + +/** + * Composable for fetching media assets from cloud environment + * Includes execution time from history API + */ +export function useCloudMediaAssets() { + const loading = ref(false) + const error = ref(null) + + /** + * Fetch list of assets from cloud with execution time + * @param directory - 'input' or 'output' + * @returns Array of AssetItem with execution time in user_metadata + */ + const fetchMediaList = async ( + directory: 'input' | 'output' + ): Promise => { + loading.value = true + error.value = null + + try { + // For input directory, just return assets without history + if (directory === 'input') { + const assets = await assetService.getAssetsByTag(directory) + return assets + } + + // For output directory, fetch history data and convert to AssetItem format + const historyResponse = await api.getHistory(200) + + if (!historyResponse?.History) { + return [] + } + + // Convert history items to AssetItem format + const assetItems: AssetItem[] = [] + + historyResponse.History.forEach((historyItem: HistoryTaskItem) => { + // Create TaskItemImpl to use existing logic + const taskItem = new TaskItemImpl( + historyItem.taskType, + historyItem.prompt, + historyItem.status, + historyItem.outputs + ) + + // Only process completed tasks + if (taskItem.displayStatus === 'Completed' && taskItem.outputs) { + // Get execution time + const executionTimeInSeconds = taskItem.executionTimeInSeconds + + // Process each output + taskItem.flatOutputs.forEach((output) => { + // Only include output type files (not temp previews) + if (output.type === 'output' && output.supportsPreview) { + // Truncate filename if longer than 15 characters + let displayName = output.filename + if (output.filename.length > 20) { + // Get file extension + const lastDotIndex = output.filename.lastIndexOf('.') + const nameWithoutExt = + lastDotIndex > -1 + ? output.filename.substring(0, lastDotIndex) + : output.filename + const extension = + lastDotIndex > -1 + ? output.filename.substring(lastDotIndex) + : '' + + // If name without extension is still long, truncate it + if (nameWithoutExt.length > 10) { + displayName = + nameWithoutExt.substring(0, 10) + + '...' + + nameWithoutExt.substring(nameWithoutExt.length - 10) + + extension + } + } + + assetItems.push({ + id: `${taskItem.promptId}-${output.nodeId}-${output.filename}`, + name: displayName, + size: 0, // We don't have size info from history + created_at: taskItem.executionStartTimestamp + ? new Date(taskItem.executionStartTimestamp).toISOString() + : new Date().toISOString(), + tags: ['output'], + preview_url: output.url, + user_metadata: { + originalFilename: output.filename, // Store original filename + promptId: taskItem.promptId, + nodeId: output.nodeId, + subfolder: output.subfolder, + ...(executionTimeInSeconds && { + executionTimeInSeconds + }), + ...(output.format && { + format: output.format + }), + ...(taskItem.workflow && { + workflow: taskItem.workflow + }) + } + }) + } + }) + } + }) + + return assetItems + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error' + console.error(`Error fetching ${directory} cloud assets:`, errorMessage) + error.value = errorMessage + return [] + } finally { + loading.value = false + } + } + + return { + loading, + error, + fetchMediaList + } +} diff --git a/src/composables/useInternalMediaAssets.ts b/src/composables/useInternalMediaAssets.ts new file mode 100644 index 000000000..45fab3388 --- /dev/null +++ b/src/composables/useInternalMediaAssets.ts @@ -0,0 +1,129 @@ +import { ref } from 'vue' + +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import type { HistoryTaskItem } from '@/schemas/apiSchema' +import { api } from '@/scripts/api' +import { TaskItemImpl } from '@/stores/queueStore' + +/** + * Composable for fetching media assets from local environment + * Uses the same logic as QueueSidebarTab for history processing + */ +export function useInternalMediaAssets() { + const loading = ref(false) + const error = ref(null) + + /** + * Fetch list of files from input or output directory with execution time + * @param directory - 'input' or 'output' + * @returns Array of AssetItem with execution time in user_metadata + */ + const fetchMediaList = async ( + directory: 'input' | 'output' + ): Promise => { + loading.value = true + error.value = null + + try { + // For input directory, fetch files without history + if (directory === 'input') { + const response = await fetch(api.internalURL(`/files/${directory}`), { + headers: { + 'Comfy-User': api.user + } + }) + if (!response.ok) { + throw new Error(`Failed to fetch ${directory} files`) + } + const filenames: string[] = await response.json() + + return filenames.map((name, index) => ({ + id: `${directory}-${index}-${name}`, + name, + size: 0, + created_at: new Date().toISOString(), + tags: [directory], + preview_url: api.apiURL( + `/view?filename=${encodeURIComponent(name)}&type=${directory}` + ) + })) + } + + // For output directory, use history data like QueueSidebarTab + const historyResponse = await api.getHistory(200) + + if (!historyResponse?.History) { + return [] + } + + const assetItems: AssetItem[] = [] + + // Process history items using TaskItemImpl like QueueSidebarTab + historyResponse.History.forEach((historyItem: HistoryTaskItem) => { + // Create TaskItemImpl to use the same logic as QueueSidebarTab + const taskItem = new TaskItemImpl( + 'History', + historyItem.prompt, + historyItem.status, + historyItem.outputs + ) + + // Only process completed tasks + if (taskItem.displayStatus === 'Completed' && taskItem.outputs) { + const executionTimeInSeconds = taskItem.executionTimeInSeconds + const executionStartTimestamp = taskItem.executionStartTimestamp + + // Process each output using flatOutputs like QueueSidebarTab + taskItem.flatOutputs.forEach((output) => { + // Only include output type files (not temp previews) + if (output.type === 'output' && output.supportsPreview) { + assetItems.push({ + id: `${taskItem.promptId}-${output.nodeId}-${output.filename}`, + name: output.filename, + size: 0, + created_at: executionStartTimestamp + ? new Date(executionStartTimestamp).toISOString() + : new Date().toISOString(), + tags: ['output'], + preview_url: output.url, + user_metadata: { + promptId: taskItem.promptId, + nodeId: output.nodeId, + subfolder: output.subfolder, + ...(executionTimeInSeconds && { + executionTimeInSeconds + }), + ...(output.format && { + format: output.format + }), + ...(taskItem.workflow && { + workflow: taskItem.workflow + }) + } + }) + } + }) + } + }) + + // Sort by creation date (newest first) + return assetItems.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error' + console.error(`Error fetching ${directory} assets:`, errorMessage) + error.value = errorMessage + return [] + } finally { + loading.value = false + } + } + + return { + loading, + error, + fetchMediaList + } +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 536813f3b..c4d360a1c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -581,13 +581,24 @@ "nodeLibrary": "Node Library", "workflows": "Workflows", "templates": "Templates", + "assets": "Assets", + "mediaAssets": "Media Assets", "labels": { "queue": "Queue", "nodes": "Nodes", "models": "Models", "workflows": "Workflows", - "templates": "Templates" + "templates": "Templates", + "console": "Console", + "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", diff --git a/src/platform/assets/components/MediaAssetCard.stories.ts b/src/platform/assets/components/MediaAssetCard.stories.ts index 231b86a9b..d7ccc8ac0 100644 --- a/src/platform/assets/components/MediaAssetCard.stories.ts +++ b/src/platform/assets/components/MediaAssetCard.stories.ts @@ -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 = { @@ -28,10 +28,6 @@ const meta: Meta = { }) ], 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 = { diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index 1681dcf3e..7a85bbca1 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -2,9 +2,7 @@ -