diff --git a/.github/workflows/release-version-bump.yaml b/.github/workflows/release-version-bump.yaml index 5f315228597..30254c6aa83 100644 --- a/.github/workflows/release-version-bump.yaml +++ b/.github/workflows/release-version-bump.yaml @@ -59,7 +59,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: lts/* - cache: 'pnpm' - name: Bump version id: bump-version diff --git a/.gitignore b/.gitignore index 8f69ce16429..cc195d8edd9 100644 --- a/.gitignore +++ b/.gitignore @@ -78,7 +78,7 @@ templates_repo/ vite.config.mts.timestamp-*.mjs # Linux core dumps -./core +/core *storybook.log storybook-static 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 00000000000..437669d6da5 --- /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 658498f1fd1..d049e9e2fe7 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -474,3 +474,93 @@ export function formatDuration(milliseconds: number): string { return parts.join(' ') } + +// Module scope constants to avoid re-initialization on every call +const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] +const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] +const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] +const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] + +/** + * Truncates a filename while preserving the extension + * @param filename The filename to truncate + * @param maxLength Maximum length for the filename without extension + * @returns Truncated filename with extension preserved + */ +export function truncateFilename( + filename: string, + maxLength: number = 20 +): string { + if (!filename || filename.length <= maxLength) { + return filename + } + + const lastDotIndex = filename.lastIndexOf('.') + const nameWithoutExt = + lastDotIndex > -1 ? filename.substring(0, lastDotIndex) : filename + const extension = lastDotIndex > -1 ? filename.substring(lastDotIndex) : '' + + // If the name without extension is short enough, return as is + if (nameWithoutExt.length <= maxLength) { + return filename + } + + // Calculate how to split the truncation + const halfLength = Math.floor((maxLength - 3) / 2) // -3 for '...' + const start = nameWithoutExt.substring(0, halfLength) + const end = nameWithoutExt.substring(nameWithoutExt.length - halfLength) + + return `${start}...${end}${extension}` +} + +/** + * Determines the media type from a filename's extension (singular form) + * @param filename The filename to analyze + * @returns The media type: 'image', 'video', 'audio', or '3D' + */ +export function getMediaTypeFromFilename( + filename: string +): 'image' | 'video' | 'audio' | '3D' { + if (!filename) return 'image' + const ext = filename.split('.').pop()?.toLowerCase() + if (!ext) return 'image' + + if (IMAGE_EXTENSIONS.includes(ext)) return 'image' + if (VIDEO_EXTENSIONS.includes(ext)) return 'video' + if (AUDIO_EXTENSIONS.includes(ext)) return 'audio' + if (THREE_D_EXTENSIONS.includes(ext)) return '3D' + + return 'image' +} + +/** + * @deprecated Use getMediaTypeFromFilename instead - returns plural form for legacy compatibility + * @param filename The filename to analyze + * @returns The media type in plural form: 'images', 'videos', 'audios', '3D' + */ +export function getMediaTypeFromFilenamePlural(filename: string): string { + const type = getMediaTypeFromFilename(filename) + switch (type) { + case 'image': + return 'images' + case 'video': + return 'videos' + case 'audio': + return 'audios' + case '3D': + return '3D' + default: + return 'images' + } +} + +/** + * @deprecated Use getMediaTypeFromFilename instead - kept for backward compatibility + * @param filename The filename to analyze + * @returns The media kind: 'image', 'video', 'audio', or '3D' + */ +export function getMediaKindFromFilename( + filename: string +): 'image' | 'video' | 'audio' | '3D' { + return getMediaTypeFromFilename(filename) +} diff --git a/src/components/card/CardTop.vue b/src/components/card/CardTop.vue index 6d26d5336b0..fb3a528b6ce 100644 --- a/src/components/card/CardTop.vue +++ b/src/components/card/CardTop.vue @@ -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', diff --git a/src/components/sidebar/tabs/AssetSidebarTemplate.vue b/src/components/sidebar/tabs/AssetSidebarTemplate.vue new file mode 100644 index 00000000000..d3879942534 --- /dev/null +++ b/src/components/sidebar/tabs/AssetSidebarTemplate.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue new file mode 100644 index 00000000000..a6cd2bd5f91 --- /dev/null +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -0,0 +1,291 @@ + + + diff --git a/src/components/tab/Tab.vue b/src/components/tab/Tab.vue new file mode 100644 index 00000000000..92eecd7fab1 --- /dev/null +++ b/src/components/tab/Tab.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/tab/TabList.stories.ts b/src/components/tab/TabList.stories.ts new file mode 100644 index 00000000000..96ae14cc4c1 --- /dev/null +++ b/src/components/tab/TabList.stories.ts @@ -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 = { + 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 + +export const Default: Story = { + render: (args) => ({ + components: { TabList, Tab }, + setup() { + const activeTab = ref(args.modelValue || 'tab1') + return { activeTab } + }, + template: ` + + Tab 1 + Tab 2 + Tab 3 + +
+ Selected tab: {{ activeTab }} +
+ ` + }), + args: { + modelValue: 'tab1' + } +} + +export const ManyTabs: Story = { + render: () => ({ + components: { TabList, Tab }, + setup() { + const activeTab = ref('tab1') + return { activeTab } + }, + template: ` + + Dashboard + Analytics + Reports + Settings + Profile + +
+ Selected tab: {{ activeTab }} +
+ ` + }) +} + +export const WithIcons: Story = { + render: () => ({ + components: { TabList, Tab }, + setup() { + const activeTab = ref('home') + return { activeTab } + }, + template: ` + + + + Home + + + + Users + + + + Settings + + +
+ Selected tab: {{ activeTab }} +
+ ` + }) +} + +export const LongLabels: Story = { + render: () => ({ + components: { TabList, Tab }, + setup() { + const activeTab = ref('overview') + return { activeTab } + }, + template: ` + + Project Overview + Documentation & Guides + Deployment Settings + Monitoring & Analytics + +
+ Selected tab: {{ activeTab }} +
+ ` + }) +} + +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: ` +
+
+

Example: Media Assets

+ + Imported + Generated + +
+ +
+
+

Showing imported assets...

+
+
+

Showing generated assets...

+
+
+ +
+ Current tab value: {{ activeTab }} +
+
+ ` + }) +} diff --git a/src/components/tab/TabList.vue b/src/components/tab/TabList.vue new file mode 100644 index 00000000000..3f0f909065e --- /dev/null +++ b/src/components/tab/TabList.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/composables/sidebarTabs/useAssetsSidebarTab.ts b/src/composables/sidebarTabs/useAssetsSidebarTab.ts new file mode 100644 index 00000000000..ce37531733d --- /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/locales/en/main.json b/src/locales/en/main.json index 210468449a4..1a8409f3368 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -595,6 +595,9 @@ "nodeLibrary": "Node Library", "workflows": "Workflows", "templates": "Templates", + "assets": "Assets", + "mediaAssets": "Media Assets", + "backToAssets": "Back to all assets", "labels": { "queue": "Queue", "nodes": "Nodes", @@ -602,8 +605,15 @@ "workflows": "Workflows", "templates": "Templates", "console": "Console", - "menu": "Menu" - }, + "menu": "Menu", + "assets": "Assets", + "imported": "Imported", + "generated": "Generated" + }, + "noFilesFound": "No files found", + "noImportedFiles": "No imported files found", + "noGeneratedFiles": "No generated files found", + "noFilesFoundMessage": "Upload files or generate content to see them here", "browseTemplates": "Browse example templates", "openWorkflow": "Open workflow in local file system", "newBlankWorkflow": "Create a new blank workflow", diff --git a/src/platform/assets/components/MediaAssetActions.vue b/src/platform/assets/components/MediaAssetActions.vue index 73144d4d22f..74e29fb2373 100644 --- a/src/platform/assets/components/MediaAssetActions.vue +++ b/src/platform/assets/components/MediaAssetActions.vue @@ -3,7 +3,7 @@ - + diff --git a/src/platform/assets/components/MediaAssetCard.stories.ts b/src/platform/assets/components/MediaAssetCard.stories.ts index 231b86a9b2a..d7ccc8ac0f3 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 1681dcf3ef2..c7a70cbcc8a 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -2,9 +2,7 @@ -