feat: add job folder view for grouped batch outputs in media assets

- Add folder view to display all outputs from a single batch job
  - Show output count badge on assets with multiple batch outputs
  - Add job ID display and copy functionality in folder view header
  - Display execution time for batch jobs
  - Implement download functionality for output assets only
  - Add inspect action to asset more menu
  - Extract prompt ID from asset IDs using new UUID utility
  - Add comprehensive tests for UUID extraction utilities
This commit is contained in:
Jin Yi
2025-10-20 21:37:48 +09:00
parent bd23cccde3
commit 72e130decf
8 changed files with 468 additions and 44 deletions

View File

@@ -1,6 +1,39 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.mediaAssets')">
<SidebarTabTemplate
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets')"
>
<template v-if="isInFolderView" #tool-buttons>
<div class="flex w-full items-center gap-2">
<span class="font-medium"
>Job ID: {{ folderPromptId?.substring(0, 8) }}</span
>
<button
class="rounded p-1 transition-colors hover:bg-neutral-100 dark-theme:hover:bg-neutral-700"
:title="$t('g.copy')"
@click="copyJobId"
>
<i class="icon-[lucide--copy] size-4" />
</button>
<span class="ml-auto text-sm text-neutral-500">
{{ formatExecutionTime(folderExecutionTime) }}
</span>
</div>
</template>
<template #header>
<!-- Job Detail View Header -->
<div
v-if="isInFolderView"
class="border-b border-neutral-300 px-4 pt-2 pb-3 dark-theme:border-neutral-700"
>
<button
class="flex items-center gap-2 rounded bg-neutral-100 px-3 py-1.5 text-sm transition-colors hover:bg-neutral-200 dark-theme:bg-neutral-700 dark-theme:hover:bg-neutral-600"
@click="exitFolderView"
>
<i class="icon-[lucide--arrow-left] size-4" />
<span>Back to all assets</span>
</button>
</div>
<!-- Normal Tab View -->
<Tabs v-model:value="activeTab" class="w-full">
<TabList class="border-b border-neutral-300">
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
@@ -10,7 +43,7 @@
</template>
<template #body>
<VirtualGrid
v-if="mediaAssets.length"
v-if="displayAssets.length"
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
@@ -23,8 +56,15 @@
<MediaAssetCard
:asset="item"
:selected="selectedAsset?.id === item.id"
:show-output-count="
activeTab === 'output' &&
!isInFolderView &&
(item.user_metadata?.outputCount as number) > 1
"
:output-count="(item.user_metadata?.outputCount as number) || 0"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
/>
</template>
</VirtualGrid>
@@ -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<AssetItem[]>([])
const selectedAsset = ref<AssetItem | null>(null)
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(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<string, AssetItem[]>()
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`
}
</script>

View File

@@ -3,7 +3,7 @@
<IconButton size="sm" @click="handleDelete">
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton size="sm" @click="handleDownload">
<IconButton v-if="assetType !== 'input'" size="sm" @click="handleDownload">
<i class="icon-[lucide--download] size-4" />
</IconButton>
<MoreButton
@@ -12,14 +12,14 @@
@menu-closed="emit('menuStateChanged', false)"
>
<template #default="{ close }">
<MediaAssetMoreMenu :close="close" />
<MediaAssetMoreMenu :close="close" @inspect="emit('inspect')" />
</template>
</MoreButton>
</IconGroup>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { computed, inject } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
@@ -31,11 +31,19 @@ import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
const emit = defineEmits<{
menuStateChanged: [isOpen: boolean]
inspect: []
}>()
const { asset } = inject(MediaAssetKey)!
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
// Get asset type from context or tags
const assetType = computed(() => {
// Check if asset has tags property (AssetItem type)
const assetWithTags = asset.value as any
return context?.value?.type || assetWithTags?.tags?.[0] || 'output'
})
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
@@ -44,7 +52,7 @@ const handleDelete = () => {
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset(asset.value.id)
actions.downloadAsset()
}
}
</script>

View File

@@ -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 @@
<template v-if="showActionsOverlay" #top-left>
<MediaAssetActions
@menu-state-changed="isMenuOpen = $event"
@inspect="handleZoomClick"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
/>
@@ -78,12 +79,15 @@
</template>
<!-- Output count (bottom-right) - show on hover even when playing -->
<template v-if="showOutputCount" #bottom-right>
<template
v-if="showOutputCount && outputCount && outputCount > 1"
#bottom-right
>
<IconTextButton
type="secondary"
size="sm"
:label="'0'"
@click.stop="actions.openMoreOutputs(asset?.id || '')"
:label="String(outputCount || 0)"
@click.stop="handleOutputCountClick"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
@@ -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<HTMLElement>()
@@ -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')
}
</script>

View File

@@ -63,6 +63,7 @@
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
<IconTextButton
v-if="showCopyJobId"
type="transparent"
class="dark-theme:text-white"
label="Copy job ID"
@@ -73,7 +74,7 @@
</template>
</IconTextButton>
<MediaAssetButtonDivider />
<MediaAssetButtonDivider v-if="showCopyJobId" />
<IconTextButton
type="transparent"
@@ -94,7 +95,6 @@ import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
@@ -102,16 +102,23 @@ const { close } = defineProps<{
close: () => 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()
}

View File

@@ -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,

View File

@@ -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

41
src/utils/uuidUtil.ts Normal file
View File

@@ -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)
}

View File

@@ -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()
})
})
})