diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 64cbcdc18..88e0db3b9 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -1,6 +1,39 @@ - + + + + Job ID: {{ folderPromptId?.substring(0, 8) }} + + + + + {{ formatExecutionTime(folderExecutionTime) }} + + + + + + + + Back to all assets + + + {{ $t('sideToolbar.labels.imported') }} @@ -10,7 +43,7 @@ @@ -59,6 +99,7 @@ import ProgressSpinner from 'primevue/progressspinner' import Tab from 'primevue/tab' import TabList from 'primevue/tablist' import Tabs from 'primevue/tabs' +import { useToast } from 'primevue/usetoast' import { computed, onMounted, ref, watch } from 'vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' @@ -74,6 +115,11 @@ import { getMediaTypeFromFilenamePlural } from '@/utils/formatUtil' const activeTab = ref<'input' | 'output'>('input') const mediaAssets = ref([]) const selectedAsset = ref(null) +const folderPromptId = ref(null) +const folderExecutionTime = ref(undefined) +const isInFolderView = computed(() => folderPromptId.value !== null) + +const toast = useToast() // Use unified media assets implementation that handles cloud/internal automatically const { loading, error, fetchMediaList } = useMediaAssets() @@ -81,7 +127,8 @@ const { loading, error, fetchMediaList } = useMediaAssets() const galleryActiveIndex = ref(-1) const galleryItems = computed(() => { // Convert AssetItems to ResultItemImpl format for gallery - return mediaAssets.value.map((asset) => { + // Use displayAssets instead of mediaAssets to show correct items based on view mode + return displayAssets.value.map((asset) => { const resultItem = new ResultItemImpl({ filename: asset.name, subfolder: '', @@ -102,9 +149,59 @@ const galleryItems = computed(() => { }) }) +// Group assets by promptId for output tab +const groupedAssets = computed(() => { + if (activeTab.value !== 'output' || isInFolderView.value) { + return null + } + + const groups = new Map() + + mediaAssets.value.forEach((asset) => { + const promptId = asset.user_metadata?.promptId as string + if (promptId) { + if (!groups.has(promptId)) { + groups.set(promptId, []) + } + groups.get(promptId)!.push(asset) + } + }) + + return groups +}) + +// Get display assets based on view mode +const displayAssets = computed(() => { + if (isInFolderView.value && folderPromptId.value) { + // Show all assets from the selected prompt + return mediaAssets.value.filter( + (asset) => asset.user_metadata?.promptId === folderPromptId.value + ) + } + + if (activeTab.value === 'output' && groupedAssets.value) { + // Show only the first asset from each prompt group + const firstAssets: AssetItem[] = [] + groupedAssets.value.forEach((assets) => { + if (assets.length > 0) { + // Add output count to the first asset + const firstAsset = { ...assets[0] } + firstAsset.user_metadata = { + ...firstAsset.user_metadata, + outputCount: assets.length + } + firstAssets.push(firstAsset) + } + }) + return firstAssets + } + + return mediaAssets.value +}) + // Add key property for VirtualGrid const mediaAssetsWithKey = computed(() => { - return mediaAssets.value.map((asset) => ({ + return displayAssets.value.map((asset) => ({ ...asset, key: asset.id })) @@ -137,9 +234,62 @@ const handleAssetSelect = (asset: AssetItem) => { const handleZoomClick = (asset: AssetItem) => { // Find the index of the clicked asset - const index = mediaAssets.value.findIndex((a) => a.id === asset.id) + const index = displayAssets.value.findIndex((a) => a.id === asset.id) if (index !== -1) { galleryActiveIndex.value = index } } + +const enterFolderView = (asset: AssetItem) => { + const promptId = asset.user_metadata?.promptId as string + if (promptId) { + folderPromptId.value = promptId + // Get execution time from the first asset of this prompt + const promptAssets = mediaAssets.value.filter( + (a) => a.user_metadata?.promptId === promptId + ) + if (promptAssets.length > 0) { + folderExecutionTime.value = promptAssets[0].user_metadata + ?.executionTimeInSeconds as number + } + } +} + +const exitFolderView = () => { + folderPromptId.value = null + folderExecutionTime.value = undefined +} + +const copyJobId = async () => { + if (folderPromptId.value) { + try { + await navigator.clipboard.writeText(folderPromptId.value) + toast.add({ + severity: 'success', + summary: 'Copied', + detail: 'Job ID copied to clipboard', + life: 2000 + }) + } catch (error) { + toast.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to copy Job ID', + life: 3000 + }) + } + } +} + +const formatExecutionTime = (seconds?: number): string => { + if (!seconds) return '' + + const minutes = Math.floor(seconds / 60) + const remainingSeconds = Math.floor(seconds % 60) + + if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s` + } + return `${remainingSeconds}s` +} diff --git a/src/platform/assets/components/MediaAssetActions.vue b/src/platform/assets/components/MediaAssetActions.vue index 73144d4d2..0776a9a1c 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.vue b/src/platform/assets/components/MediaAssetCard.vue index d78e29e68..c7a70cbcc 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -32,7 +32,7 @@ :asset="adaptedAsset" :context="{ type: assetType }" @view="handleZoomClick" - @download="actions.downloadAsset(asset.id)" + @download="actions.downloadAsset()" @play="actions.playAsset(asset.id)" @video-playing-state-changed="isVideoPlaying = $event" @video-controls-changed="showVideoControls = $event" @@ -44,6 +44,7 @@ @@ -78,12 +79,15 @@ - + @@ -164,14 +168,17 @@ function getBottomComponent(kind: MediaKind) { return mediaComponents.bottom[kind] || mediaComponents.bottom.image } -const { asset, loading, selected } = defineProps<{ +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': [] }>() const cardContainerRef = ref() @@ -273,12 +280,11 @@ const showHoverActions = computed( () => !loading && !!asset && isCardOrOverlayHovered.value ) -const showActionsOverlay = false -// const showActionsOverlay = computed( -// () => -// showHoverActions.value && -// (!isVideoPlaying.value || isCardOrOverlayHovered.value) -// ) +const showActionsOverlay = computed( + () => + showHoverActions.value && + (!isVideoPlaying.value || isCardOrOverlayHovered.value) +) const showZoomOverlay = computed( () => @@ -303,9 +309,7 @@ const showFileFormatChip = computed( (!isVideoPlaying.value || isCardOrOverlayHovered.value) ) -const showOutputCount = computed( - () => false // Remove output count for simplified version -) +// Remove the redundant showOutputCount computed since we're using prop directly const handleCardClick = () => { if (adaptedAsset.value) { @@ -330,4 +334,8 @@ const handleZoomClick = () => { const handleImageLoaded = (dimensions: { width: number; height: number }) => { imageDimensions.value = dimensions } + +const handleOutputCountClick = () => { + emit('output-count-click') +} diff --git a/src/platform/assets/components/MediaAssetMoreMenu.vue b/src/platform/assets/components/MediaAssetMoreMenu.vue index 62f793aa2..f0db9d480 100644 --- a/src/platform/assets/components/MediaAssetMoreMenu.vue +++ b/src/platform/assets/components/MediaAssetMoreMenu.vue @@ -63,6 +63,7 @@ - + void }>() +const emit = defineEmits<{ + inspect: [] +}>() + const { asset, context } = inject(MediaAssetKey)! const actions = useMediaAssetActions() -const galleryStore = useMediaAssetGalleryStore() const showWorkflowOptions = computed(() => context.value.type) +// Only show Copy Job ID for output assets (not for imported/input assets) +const showCopyJobId = computed(() => { + const assetType = (asset.value as any)?.tags?.[0] || context.value?.type + return assetType !== 'input' +}) + const handleInspect = () => { - if (asset.value) { - galleryStore.openSingle(asset.value) - } + emit('inspect') close() } @@ -124,7 +131,7 @@ const handleAddToWorkflow = () => { const handleDownload = () => { if (asset.value) { - actions.downloadAsset(asset.value.id) + actions.downloadAsset() } close() } @@ -143,9 +150,9 @@ const handleExportWorkflow = () => { close() } -const handleCopyJobId = () => { +const handleCopyJobId = async () => { if (asset.value) { - actions.copyAssetUrl(asset.value.id) + await actions.copyJobId() } close() } diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index 8a3166393..03116fb67 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -1,13 +1,51 @@ /* eslint-disable no-console */ +import { useToast } from 'primevue/usetoast' +import { inject } from 'vue' + +import { downloadFile } from '@/base/common/downloadUtil' +import { t } from '@/i18n' +import { api } from '@/scripts/api' +import { extractPromptIdFromAssetId } from '@/utils/uuidUtil' + +import type { AssetItem } from '../schemas/assetSchema' import type { AssetMeta } from '../schemas/mediaAssetSchema' +import { MediaAssetKey } from '../schemas/mediaAssetSchema' export function useMediaAssetActions() { + const toast = useToast() + 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 as AssetItem).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) => { @@ -18,12 +56,38 @@ export function useMediaAssetActions() { 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) + const promptId = extractPromptIdFromAssetId(asset.id) + + 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: 'Job ID copied to clipboard', + life: 2000 + }) + } catch (error) { + toast.add({ + severity: 'error', + summary: t('g.error'), + detail: 'Failed to copy job ID', + life: 3000 + }) + } } const addWorkflow = (assetId: string) => { @@ -47,7 +111,6 @@ export function useMediaAssetActions() { downloadAsset, deleteAsset, playAsset, - copyAssetUrl, copyJobId, addWorkflow, openWorkflow, diff --git a/src/platform/assets/schemas/mediaAssetSchema.ts b/src/platform/assets/schemas/mediaAssetSchema.ts index d35dad6c5..818db6b4a 100644 --- a/src/platform/assets/schemas/mediaAssetSchema.ts +++ b/src/platform/assets/schemas/mediaAssetSchema.ts @@ -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 diff --git a/src/utils/uuidUtil.ts b/src/utils/uuidUtil.ts new file mode 100644 index 000000000..edf1619ad --- /dev/null +++ b/src/utils/uuidUtil.ts @@ -0,0 +1,41 @@ +/** + * UUID utility functions + */ + +/** + * Regular expression for matching UUID v4 format + * Format: 8-4-4-4-12 (e.g., 98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3) + */ +const UUID_REGEX = + /^([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i + +/** + * Extract UUID from the beginning of a string + * @param str - The string to extract UUID from + * @returns The extracted UUID or null if not found + */ +export function extractUuidFromString(str: string): string | null { + const match = str.match(UUID_REGEX) + return match ? match[1] : null +} + +/** + * Check if a string is a valid UUID v4 + * @param str - The string to check + * @returns true if the string is a valid UUID v4 + */ +export function isValidUuid(str: string): boolean { + const fullUuidRegex = + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i + return fullUuidRegex.test(str) +} + +/** + * Extract prompt ID from asset ID + * Asset ID format: "promptId-nodeId-filename" + * @param assetId - The asset ID string + * @returns The extracted prompt ID (UUID) or null + */ +export function extractPromptIdFromAssetId(assetId: string): string | null { + return extractUuidFromString(assetId) +} diff --git a/tests-ui/tests/utils/uuidUtil.test.ts b/tests-ui/tests/utils/uuidUtil.test.ts new file mode 100644 index 000000000..384d490d9 --- /dev/null +++ b/tests-ui/tests/utils/uuidUtil.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' + +import { + extractPromptIdFromAssetId, + extractUuidFromString, + isValidUuid +} from '@/utils/uuidUtil' + +describe('uuidUtil', () => { + describe('extractUuidFromString', () => { + it('should extract UUID from the beginning of a string', () => { + const str = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-9-ComfyUI_00042_.png' + const result = extractUuidFromString(str) + expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3') + }) + + it('should extract UUID with uppercase letters', () => { + const str = 'A8B0B007-7D78-4E3F-B7A8-0F483B9CF2D3-node-file.png' + const result = extractUuidFromString(str) + expect(result).toBe('A8B0B007-7D78-4E3F-B7A8-0F483B9CF2D3') + }) + + it('should return null if no UUID at the beginning', () => { + const str = 'not-a-uuid-98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3' + const result = extractUuidFromString(str) + expect(result).toBeNull() + }) + + it('should return null for invalid UUID format', () => { + const str = '12345678-1234-1234-1234-123456789abc-extra' + const result = extractUuidFromString(str) + expect(result).toBe('12345678-1234-1234-1234-123456789abc') + }) + + it('should handle UUID without trailing content', () => { + const str = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3' + const result = extractUuidFromString(str) + expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3') + }) + + it('should return null for empty string', () => { + const result = extractUuidFromString('') + expect(result).toBeNull() + }) + + it('should return null for malformed UUID', () => { + const str = '98b0b007-7d78-4e3f-b7a8' + const result = extractUuidFromString(str) + expect(result).toBeNull() + }) + }) + + describe('isValidUuid', () => { + it('should return true for valid UUID v4', () => { + const uuid = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3' + expect(isValidUuid(uuid)).toBe(true) + }) + + it('should return true for uppercase UUID', () => { + const uuid = 'A8B0B007-7D78-4E3F-B7A8-0F483B9CF2D3' + expect(isValidUuid(uuid)).toBe(true) + }) + + it('should return true for mixed case UUID', () => { + const uuid = 'a8B0b007-7D78-4e3F-B7a8-0F483b9CF2d3' + expect(isValidUuid(uuid)).toBe(true) + }) + + it('should return false for UUID with extra characters', () => { + const uuid = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-extra' + expect(isValidUuid(uuid)).toBe(false) + }) + + it('should return false for incomplete UUID', () => { + const uuid = '98b0b007-7d78-4e3f-b7a8' + expect(isValidUuid(uuid)).toBe(false) + }) + + it('should return false for UUID with wrong segment lengths', () => { + const uuid = '98b0b0007-7d78-4e3f-b7a8-0f483b9cf2d3' + expect(isValidUuid(uuid)).toBe(false) + }) + + it('should return false for UUID with invalid characters', () => { + const uuid = '98b0b007-7d78-4e3f-b7a8-0f483b9cfzd3' + expect(isValidUuid(uuid)).toBe(false) + }) + + it('should return false for empty string', () => { + expect(isValidUuid('')).toBe(false) + }) + + it('should return false for null-like values', () => { + expect(isValidUuid('null')).toBe(false) + expect(isValidUuid('undefined')).toBe(false) + }) + }) + + describe('extractPromptIdFromAssetId', () => { + it('should extract promptId from typical asset ID', () => { + const assetId = + '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-9-ComfyUI_00042_.png' + const result = extractPromptIdFromAssetId(assetId) + expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3') + }) + + it('should extract promptId with multiple dashes in filename', () => { + const assetId = + '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-15-my-image-file.png' + const result = extractPromptIdFromAssetId(assetId) + expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3') + }) + + it('should handle asset ID with just UUID and node ID', () => { + const assetId = '98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3-42' + const result = extractPromptIdFromAssetId(assetId) + expect(result).toBe('98b0b007-7d78-4e3f-b7a8-0f483b9cf2d3') + }) + + it('should return null for input folder asset ID', () => { + const assetId = 'input-0-myimage.png' + const result = extractPromptIdFromAssetId(assetId) + expect(result).toBeNull() + }) + + it('should return null for malformed asset ID', () => { + const assetId = 'not-a-valid-asset-id' + const result = extractPromptIdFromAssetId(assetId) + expect(result).toBeNull() + }) + + it('should handle asset ID with special characters in filename', () => { + const assetId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890-1-image(1)[2].png' + const result = extractPromptIdFromAssetId(assetId) + expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890') + }) + + it('should return null for empty string', () => { + const result = extractPromptIdFromAssetId('') + expect(result).toBeNull() + }) + + it('should handle asset ID with underscores in UUID position', () => { + const assetId = 'output_1_image.png' + const result = extractPromptIdFromAssetId(assetId) + expect(result).toBeNull() + }) + }) +})