mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 14:54:12 +00:00
Add expandable output stacks to assets list view (#8283)
Add expandable output stacks to the assets list view. Monolith ver. of https://github.com/Comfy-Org/ComfyUI_frontend/pull/8298 and its children List view currently collapses multi-output jobs into a single row, which makes sibling outputs easy to miss and causes selection/zoom behavior to drift once items are expanded elsewhere. This change adds a stack toggle to list rows, expands child outputs derived from job data, and keeps list-view selection and gallery navigation aligned with the expanded list. Output mapping and “load full outputs” checks are centralized so folder view and stacks share the same helper, and job-detail parsing now yields previewable outputs for the list view. Asset actions now prefer metadata prompt IDs to support the composite IDs used by stacked outputs. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8283-Add-expandable-output-stacks-to-assets-list-view-2f16d73d365081a99fc6f1519ac2e57c) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
This commit is contained in:
@@ -264,6 +264,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
|
||||
throw new Error('Asset not found in media assets panel')
|
||||
}
|
||||
assetSelectionStore.setSelection([assetId])
|
||||
assetSelectionStore.setLastSelectedAssetId(assetId)
|
||||
}
|
||||
|
||||
const inspectJobAsset = wrapWithErrorHandlingAsync(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Active Jobs Grid -->
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||
:style="gridStyle"
|
||||
>
|
||||
@@ -70,12 +70,14 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
isInFolderView = false,
|
||||
assetType = 'output',
|
||||
showOutputCount,
|
||||
getOutputCount
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isInFolderView?: boolean
|
||||
assetType?: 'input' | 'output'
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { toRef } from 'vue'
|
||||
|
||||
import type { JobAction } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
|
||||
import { setMockJobItems } from '@/storybook/mocks/useJobList'
|
||||
@@ -138,16 +140,33 @@ function renderAssetsSidebarListView(args: StoryArgs) {
|
||||
setup() {
|
||||
setMockJobItems(args.jobs)
|
||||
setMockJobActions(args.actionsByJobId ?? {})
|
||||
const { assetItems, selectableAssets, isStackExpanded, toggleStack } =
|
||||
useOutputStacks({
|
||||
assets: toRef(args, 'assets')
|
||||
})
|
||||
const selectedIds = new Set(args.selectedAssetIds ?? [])
|
||||
function isSelected(assetId: string) {
|
||||
return selectedIds.has(assetId)
|
||||
}
|
||||
|
||||
return { args, isSelected }
|
||||
return {
|
||||
args,
|
||||
assetItems,
|
||||
selectableAssets,
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
|
||||
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
|
||||
<AssetsSidebarListView
|
||||
:asset-items="assetItems"
|
||||
:selectable-assets="selectableAssets"
|
||||
:is-selected="isSelected"
|
||||
:is-stack-expanded="isStackExpanded"
|
||||
:toggle-stack="toggleStack"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assets.length"
|
||||
v-if="assetItems.length"
|
||||
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
@@ -79,7 +79,12 @@
|
||||
type: getMediaTypeFromFilename(item.asset.name)
|
||||
})
|
||||
"
|
||||
:class="getAssetCardClass(isSelected(item.asset.id))"
|
||||
:class="
|
||||
cn(
|
||||
getAssetCardClass(isSelected(item.asset.id)),
|
||||
item.isChild && 'pl-6'
|
||||
)
|
||||
"
|
||||
:preview-url="item.asset.preview_url"
|
||||
:preview-alt="item.asset.name"
|
||||
:icon-name="
|
||||
@@ -87,10 +92,14 @@
|
||||
"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
:stack-count="getStackCount(item.asset)"
|
||||
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
|
||||
:stack-expanded="isStackExpanded(item.asset)"
|
||||
@mouseenter="onAssetEnter(item.asset.id)"
|
||||
@mouseleave="onAssetLeave(item.asset.id)"
|
||||
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset, selectableAssets)"
|
||||
@stack-toggle="void toggleStack(item.asset)"
|
||||
>
|
||||
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
||||
<Button
|
||||
@@ -120,6 +129,7 @@ import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
@@ -136,19 +146,25 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
assetItems,
|
||||
selectableAssets,
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack,
|
||||
assetType = 'output'
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
assetItems: OutputStackListItem[]
|
||||
selectableAssets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isStackExpanded: (asset: AssetItem) => boolean
|
||||
toggleStack: (asset: AssetItem) => Promise<void>
|
||||
assetType?: 'input' | 'output'
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
@@ -162,9 +178,6 @@ const isQueuePanelV2Enabled = computed(() =>
|
||||
)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
|
||||
type AssetListItem = { key: string; asset: AssetItem }
|
||||
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state))
|
||||
)
|
||||
@@ -176,13 +189,6 @@ const hoveredJob = computed(() =>
|
||||
)
|
||||
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
|
||||
|
||||
const assetItems = computed<AssetListItem[]>(() =>
|
||||
assets.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
}))
|
||||
)
|
||||
|
||||
const listGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)',
|
||||
@@ -212,6 +218,19 @@ function getAssetSecondaryText(asset: AssetItem): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function getStackCount(asset: AssetItem): number | undefined {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (typeof metadata?.outputCount === 'number') {
|
||||
return metadata.outputCount
|
||||
}
|
||||
|
||||
if (Array.isArray(metadata?.allOutputs)) {
|
||||
return metadata.allOutputs.length
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getAssetCardClass(selected: boolean): string {
|
||||
return cn(
|
||||
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
v-if="isQueuePanelV2Enabled && !isInFolderView"
|
||||
class="flex items-center justify-between px-2 py-2 2xl:px-4"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
@@ -98,8 +98,11 @@
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<AssetsSidebarListView
|
||||
v-if="isListView"
|
||||
:assets="displayAssets"
|
||||
:asset-items="listViewAssetItems"
|
||||
:is-selected="isSelected"
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
:asset-type="activeTab"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@@ -109,6 +112,7 @@
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:is-in-folder-view="isInFolderView"
|
||||
:asset-type="activeTab"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@@ -230,12 +234,13 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -243,12 +248,6 @@ import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface JobOutputItem {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -323,6 +322,7 @@ const {
|
||||
hasSelection,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
reconcileSelection,
|
||||
getOutputCount,
|
||||
getTotalOutputCount,
|
||||
activate: activateSelection,
|
||||
@@ -392,7 +392,21 @@ const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
})
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
|
||||
const {
|
||||
assetItems: listViewAssetItems,
|
||||
selectableAssets: listViewSelectableAssets,
|
||||
isStackExpanded: isListViewStackExpanded,
|
||||
toggleStack: toggleListViewStack
|
||||
} = useOutputStacks({
|
||||
assets: computed(() => displayAssets.value)
|
||||
})
|
||||
|
||||
const visibleAssets = computed(() => {
|
||||
if (!isListView.value) return displayAssets.value
|
||||
return listViewSelectableAssets.value
|
||||
})
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
@@ -412,7 +426,10 @@ const showEmptyState = computed(
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
watch(visibleAssets, (newAssets) => {
|
||||
// Alternative: keep hidden selections and surface them in UI; for now prune
|
||||
// so selection stays consistent with what this view can act on.
|
||||
reconcileSelection(newAssets)
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
(asset) => asset.id === currentGalleryAssetId.value
|
||||
@@ -430,7 +447,7 @@ watch(galleryActiveIndex, (index) => {
|
||||
})
|
||||
|
||||
const galleryItems = computed(() => {
|
||||
return displayAssets.value.map((asset) => {
|
||||
return visibleAssets.value.map((asset) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
@@ -470,9 +487,10 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleAssetSelect = (asset: AssetItem) => {
|
||||
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
handleAssetClick(asset, index, displayAssets.value)
|
||||
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||
const assetList = assets ?? visibleAssets.value
|
||||
const index = assetList.findIndex((a) => a.id === asset.id)
|
||||
handleAssetClick(asset, index, assetList)
|
||||
}
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
@@ -557,7 +575,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
currentGalleryAssetId.value = asset.id
|
||||
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
|
||||
if (index !== -1) {
|
||||
galleryActiveIndex.value = index
|
||||
}
|
||||
@@ -570,7 +588,7 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
return
|
||||
}
|
||||
|
||||
const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata
|
||||
const { promptId, executionTimeInSeconds } = metadata
|
||||
|
||||
if (!promptId) {
|
||||
console.warn('Missing required folder view data')
|
||||
@@ -580,62 +598,21 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
folderPromptId.value = promptId
|
||||
folderExecutionTime.value = executionTimeInSeconds
|
||||
|
||||
// Determine which outputs to display
|
||||
let outputsToDisplay = allOutputs ?? []
|
||||
|
||||
// If outputCount indicates more outputs than we have, fetch full outputs
|
||||
const needsFullOutputs =
|
||||
typeof outputCount === 'number' &&
|
||||
outputCount > 1 &&
|
||||
outputsToDisplay.length < outputCount
|
||||
|
||||
if (needsFullOutputs) {
|
||||
try {
|
||||
const jobDetail = await getJobDetail(promptId)
|
||||
if (jobDetail?.outputs) {
|
||||
// Convert job outputs to ResultItemImpl array
|
||||
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
|
||||
([nodeId, nodeOutputs]) =>
|
||||
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
|
||||
(items as JobOutputItem[])
|
||||
.map(
|
||||
(item) =>
|
||||
new ResultItemImpl({
|
||||
...item,
|
||||
nodeId,
|
||||
mediaType
|
||||
})
|
||||
)
|
||||
.filter((r) => r.supportsPreview)
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch job detail for folder view:', error)
|
||||
outputsToDisplay = []
|
||||
}
|
||||
let folderItems: AssetItem[] = []
|
||||
try {
|
||||
folderItems = await resolveOutputAssetItems(metadata, {
|
||||
createdAt: asset.created_at
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve outputs for folder view:', error)
|
||||
}
|
||||
|
||||
if (outputsToDisplay.length === 0) {
|
||||
if (folderItems.length === 0) {
|
||||
console.warn('No outputs available for folder view')
|
||||
return
|
||||
}
|
||||
|
||||
folderAssets.value = outputsToDisplay.map((output) => ({
|
||||
id: `${output.nodeId}-${output.filename}`,
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: asset.created_at,
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: {
|
||||
promptId,
|
||||
nodeId: output.nodeId,
|
||||
subfolder: output.subfolder,
|
||||
executionTimeInSeconds,
|
||||
workflow: metadata.workflow
|
||||
}
|
||||
}))
|
||||
folderAssets.value = folderItems
|
||||
}
|
||||
|
||||
const exitFolderView = () => {
|
||||
|
||||
@@ -74,13 +74,46 @@
|
||||
<div v-if="$slots.actions" class="relative z-1 flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="typeof stackCount === 'number' && stackCount > 1"
|
||||
class="relative z-1 flex shrink-0 items-center"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-1 font-bold"
|
||||
:aria-label="stackIndicatorLabel"
|
||||
:aria-expanded="stackExpanded"
|
||||
@click.stop="emit('stack-toggle')"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--layers] size-4" />
|
||||
<span class="text-xs leading-none">{{ stackCount }}</span>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
stackExpanded
|
||||
? 'icon-[lucide--chevron-down]'
|
||||
: 'icon-[lucide--chevron-right]',
|
||||
'size-3'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'stack-toggle': []
|
||||
}>()
|
||||
|
||||
const {
|
||||
previewUrl,
|
||||
previewAlt = '',
|
||||
@@ -90,6 +123,9 @@ const {
|
||||
iconWrapperClass,
|
||||
primaryText,
|
||||
secondaryText,
|
||||
stackCount,
|
||||
stackIndicatorLabel,
|
||||
stackExpanded = false,
|
||||
progressTotalPercent,
|
||||
progressCurrentPercent
|
||||
} = defineProps<{
|
||||
@@ -101,6 +137,9 @@ const {
|
||||
iconWrapperClass?: string
|
||||
primaryText?: string
|
||||
secondaryText?: string
|
||||
stackCount?: number
|
||||
stackIndicatorLabel?: string
|
||||
stackExpanded?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
}>()
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { useAssetSelection } from './useAssetSelection'
|
||||
import { useAssetSelectionStore } from './useAssetSelectionStore'
|
||||
|
||||
function createMockAssets(count: number): AssetItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
@@ -43,6 +44,79 @@ describe('useAssetSelection', () => {
|
||||
mockMetaKey.value = false
|
||||
})
|
||||
|
||||
describe('reconcileSelection', () => {
|
||||
it('prunes selection to visible assets', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] }
|
||||
]
|
||||
|
||||
store.setSelection(['a', 'b'])
|
||||
store.setLastSelectedIndex(1)
|
||||
store.setLastSelectedAssetId('b')
|
||||
|
||||
selection.reconcileSelection([assets[1]])
|
||||
|
||||
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
|
||||
expect(store.lastSelectedIndex).toBe(0)
|
||||
expect(store.lastSelectedAssetId).toBe('b')
|
||||
})
|
||||
|
||||
it('clears selection when no visible assets remain', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
|
||||
store.setSelection(['a'])
|
||||
store.setLastSelectedIndex(0)
|
||||
store.setLastSelectedAssetId('a')
|
||||
|
||||
selection.reconcileSelection([])
|
||||
|
||||
expect(store.selectedAssetIds.size).toBe(0)
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
expect(store.lastSelectedAssetId).toBeNull()
|
||||
})
|
||||
|
||||
it('recomputes the anchor index when assets reorder', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] }
|
||||
]
|
||||
|
||||
store.setSelection(['a'])
|
||||
store.setLastSelectedIndex(0)
|
||||
store.setLastSelectedAssetId('a')
|
||||
|
||||
selection.reconcileSelection([assets[1], assets[0]])
|
||||
|
||||
expect(store.lastSelectedIndex).toBe(1)
|
||||
expect(store.lastSelectedAssetId).toBe('a')
|
||||
})
|
||||
|
||||
it('clears anchor when the anchored asset is no longer visible', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] }
|
||||
]
|
||||
|
||||
store.setSelection(['a', 'b'])
|
||||
store.setLastSelectedIndex(0)
|
||||
store.setLastSelectedAssetId('a')
|
||||
|
||||
selection.reconcileSelection([assets[1]])
|
||||
|
||||
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
expect(store.lastSelectedAssetId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAssetClick - normal click', () => {
|
||||
it('selects single asset and clears previous selection', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
|
||||
@@ -21,6 +21,25 @@ export function useAssetSelection() {
|
||||
const metaKey = computed(() => isActive.value && metaKeyRaw.value)
|
||||
const cmdOrCtrlKey = computed(() => ctrlKey.value || metaKey.value)
|
||||
|
||||
function setAnchor(index: number, assetId: string | null) {
|
||||
selectionStore.setLastSelectedIndex(index)
|
||||
selectionStore.setLastSelectedAssetId(assetId)
|
||||
}
|
||||
|
||||
function syncAnchorFromAssets(assets: AssetItem[]) {
|
||||
const anchorId = selectionStore.lastSelectedAssetId
|
||||
const anchorIndex = anchorId
|
||||
? assets.findIndex((asset) => asset.id === anchorId)
|
||||
: -1
|
||||
|
||||
if (anchorIndex !== -1) {
|
||||
selectionStore.setLastSelectedIndex(anchorIndex)
|
||||
return
|
||||
}
|
||||
|
||||
setAnchor(-1, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle asset click with modifier keys for selection
|
||||
* @param asset The clicked asset
|
||||
@@ -56,14 +75,14 @@ export function useAssetSelection() {
|
||||
// Ctrl/Cmd + Click: Toggle individual selection
|
||||
if (cmdOrCtrlKey.value) {
|
||||
selectionStore.toggleSelection(assetId)
|
||||
selectionStore.setLastSelectedIndex(index)
|
||||
setAnchor(index, assetId)
|
||||
return
|
||||
}
|
||||
|
||||
// Normal Click: Single selection
|
||||
selectionStore.clearSelection()
|
||||
selectionStore.addToSelection(assetId)
|
||||
selectionStore.setLastSelectedIndex(index)
|
||||
setAnchor(index, assetId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +92,8 @@ export function useAssetSelection() {
|
||||
const allIds = allAssets.map((a) => a.id)
|
||||
selectionStore.setSelection(allIds)
|
||||
if (allAssets.length > 0) {
|
||||
selectionStore.setLastSelectedIndex(allAssets.length - 1)
|
||||
const lastIndex = allAssets.length - 1
|
||||
setAnchor(lastIndex, allAssets[lastIndex].id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +104,39 @@ export function useAssetSelection() {
|
||||
return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
|
||||
}
|
||||
|
||||
function reconcileSelection(assets: AssetItem[]) {
|
||||
if (selectionStore.selectedAssetIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (assets.length === 0) {
|
||||
selectionStore.clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
const visibleIds = new Set(assets.map((asset) => asset.id))
|
||||
const nextSelectedIds: string[] = []
|
||||
|
||||
for (const id of selectionStore.selectedAssetIds) {
|
||||
if (visibleIds.has(id)) {
|
||||
nextSelectedIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (nextSelectedIds.length === selectionStore.selectedAssetIds.size) {
|
||||
syncAnchorFromAssets(assets)
|
||||
return
|
||||
}
|
||||
|
||||
if (nextSelectedIds.length === 0) {
|
||||
selectionStore.clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
selectionStore.setSelection(nextSelectedIds)
|
||||
syncAnchorFromAssets(assets)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the output count for a single asset
|
||||
* Same logic as in AssetsSidebarTab.vue
|
||||
@@ -113,7 +166,7 @@ export function useAssetSelection() {
|
||||
function deactivate() {
|
||||
isActive.value = false
|
||||
// Reset selection state to ensure clean state when deactivated
|
||||
selectionStore.reset()
|
||||
selectionStore.clearSelection()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -128,10 +181,9 @@ export function useAssetSelection() {
|
||||
selectAll,
|
||||
clearSelection: () => selectionStore.clearSelection(),
|
||||
getSelectedAssets,
|
||||
reconcileSelection,
|
||||
getOutputCount,
|
||||
getTotalOutputCount,
|
||||
reset: () => selectionStore.reset(),
|
||||
|
||||
// Lifecycle management
|
||||
activate,
|
||||
deactivate,
|
||||
|
||||
@@ -68,6 +68,16 @@ describe('useAssetSelectionStore', () => {
|
||||
expect(store.selectedCount).toBe(0)
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('resets lastSelectedAssetId', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.setLastSelectedAssetId('asset-1')
|
||||
|
||||
store.clearSelection()
|
||||
|
||||
expect(store.lastSelectedAssetId).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleSelection', () => {
|
||||
@@ -106,19 +116,6 @@ describe('useAssetSelectionStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears selection and resets index', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.setLastSelectedIndex(5)
|
||||
|
||||
store.reset()
|
||||
|
||||
expect(store.selectedCount).toBe(0)
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('hasSelection returns true when items are selected', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
|
||||
@@ -5,6 +5,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
|
||||
// State
|
||||
const selectedAssetIds = ref<Set<string>>(new Set())
|
||||
const lastSelectedIndex = ref<number>(-1)
|
||||
const lastSelectedAssetId = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const selectedCount = computed(() => selectedAssetIds.value.size)
|
||||
@@ -27,6 +28,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
|
||||
function clearSelection() {
|
||||
selectedAssetIds.value.clear()
|
||||
lastSelectedIndex.value = -1
|
||||
lastSelectedAssetId.value = null
|
||||
}
|
||||
|
||||
function toggleSelection(assetId: string) {
|
||||
@@ -45,16 +47,15 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
|
||||
lastSelectedIndex.value = index
|
||||
}
|
||||
|
||||
// Reset function for cleanup
|
||||
function reset() {
|
||||
selectedAssetIds.value.clear()
|
||||
lastSelectedIndex.value = -1
|
||||
function setLastSelectedAssetId(assetId: string | null) {
|
||||
lastSelectedAssetId.value = assetId
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedAssetIds: computed(() => selectedAssetIds.value),
|
||||
lastSelectedIndex: computed(() => lastSelectedIndex.value),
|
||||
lastSelectedAssetId: computed(() => lastSelectedAssetId.value),
|
||||
|
||||
// Getters
|
||||
selectedCount,
|
||||
@@ -69,6 +70,6 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
|
||||
toggleSelection,
|
||||
isSelected,
|
||||
setLastSelectedIndex,
|
||||
reset
|
||||
setLastSelectedAssetId
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useMediaAssetActions() {
|
||||
): Promise<void> => {
|
||||
if (assetType === 'output') {
|
||||
const promptId =
|
||||
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
|
||||
getOutputAssetMetadata(asset.user_metadata)?.promptId || asset.id
|
||||
if (!promptId) {
|
||||
throw new Error('Unable to extract prompt ID from asset')
|
||||
}
|
||||
@@ -138,9 +138,10 @@ export function useMediaAssetActions() {
|
||||
const targetAsset = asset ?? mediaContext?.asset.value
|
||||
if (!targetAsset) return
|
||||
|
||||
// Try asset.id first (OSS), then fall back to metadata (Cloud)
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const promptId = targetAsset.id || metadata?.promptId
|
||||
const promptId =
|
||||
metadata?.promptId ||
|
||||
(getAssetType(targetAsset) === 'output' ? targetAsset.id : undefined)
|
||||
|
||||
if (!promptId) {
|
||||
toast.add({
|
||||
|
||||
205
src/platform/assets/composables/useOutputStacks.test.ts
Normal file
205
src/platform/assets/composables/useOutputStacks.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type * as OutputAssetUtil from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveOutputAssetItems: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof OutputAssetUtil>()
|
||||
return {
|
||||
...actual,
|
||||
resolveOutputAssetItems: mocks.resolveOutputAssetItems
|
||||
}
|
||||
})
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolveFn, rejectFn) => {
|
||||
resolve = resolveFn
|
||||
reject = rejectFn
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: 'parent.png',
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
user_metadata: {
|
||||
promptId: 'prompt-1',
|
||||
nodeId: 'node-1',
|
||||
subfolder: 'outputs'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useOutputStacks', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('expands stacks and exposes children as selectable assets', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
const childA = createAsset({
|
||||
id: 'child-a',
|
||||
name: 'child-a.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
const childB = createAsset({
|
||||
id: 'child-b',
|
||||
name: 'child-b.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([childA, childB])
|
||||
|
||||
const { assetItems, isStackExpanded, selectableAssets, toggleStack } =
|
||||
useOutputStacks({ assets: ref([parent]) })
|
||||
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ promptId: 'prompt-1' }),
|
||||
{
|
||||
createdAt: parent.created_at,
|
||||
excludeOutputKey: 'node-1-outputs-parent.png'
|
||||
}
|
||||
)
|
||||
expect(isStackExpanded(parent)).toBe(true)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
|
||||
parent.id,
|
||||
childA.id,
|
||||
childB.id
|
||||
])
|
||||
expect(assetItems.value[1]).toMatchObject({
|
||||
asset: childA,
|
||||
isChild: true
|
||||
})
|
||||
expect(assetItems.value[2]).toMatchObject({
|
||||
asset: childB,
|
||||
isChild: true
|
||||
})
|
||||
expect(selectableAssets.value).toEqual([parent, childA, childB])
|
||||
})
|
||||
|
||||
it('collapses an expanded stack when toggled again', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
const child = createAsset({
|
||||
id: 'child',
|
||||
name: 'child.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([child])
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
})
|
||||
|
||||
await toggleStack(parent)
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(isStackExpanded(parent)).toBe(false)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
|
||||
})
|
||||
|
||||
it('ignores assets without stack metadata', async () => {
|
||||
const asset = createAsset({
|
||||
id: 'no-meta',
|
||||
name: 'no-meta.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([asset])
|
||||
})
|
||||
|
||||
await toggleStack(asset)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
expect(isStackExpanded(asset)).toBe(false)
|
||||
expect(assetItems.value).toHaveLength(1)
|
||||
expect(assetItems.value[0].asset).toMatchObject(asset)
|
||||
})
|
||||
|
||||
it('does not expand when no children are resolved', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([])
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
})
|
||||
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(isStackExpanded(parent)).toBe(false)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
|
||||
})
|
||||
|
||||
it('does not expand when resolving children throws', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockRejectedValue(
|
||||
new Error('resolve failed')
|
||||
)
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
})
|
||||
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(isStackExpanded(parent)).toBe(false)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('guards against duplicate loads while a stack is resolving', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
const child = createAsset({
|
||||
id: 'child',
|
||||
name: 'child.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
const deferred = createDeferred<AssetItem[]>()
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockReturnValue(deferred.promise)
|
||||
|
||||
const { assetItems, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
})
|
||||
|
||||
const firstToggle = toggleStack(parent)
|
||||
const secondToggle = toggleStack(parent)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
|
||||
deferred.resolve([child])
|
||||
|
||||
await firstToggle
|
||||
await secondToggle
|
||||
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
|
||||
parent.id,
|
||||
child.id
|
||||
])
|
||||
})
|
||||
})
|
||||
138
src/platform/assets/composables/useOutputStacks.ts
Normal file
138
src/platform/assets/composables/useOutputStacks.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
getOutputKey,
|
||||
resolveOutputAssetItems
|
||||
} from '@/platform/assets/utils/outputAssetUtil'
|
||||
|
||||
export type OutputStackListItem = {
|
||||
key: string
|
||||
asset: AssetItem
|
||||
isChild?: boolean
|
||||
}
|
||||
|
||||
type UseOutputStacksOptions = {
|
||||
assets: Ref<AssetItem[]>
|
||||
}
|
||||
|
||||
export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
const expandedStackPromptIds = ref<Set<string>>(new Set())
|
||||
const stackChildrenByPromptId = ref<Record<string, AssetItem[]>>({})
|
||||
const loadingStackPromptIds = ref<Set<string>>(new Set())
|
||||
|
||||
const assetItems = computed<OutputStackListItem[]>(() => {
|
||||
const items: OutputStackListItem[] = []
|
||||
|
||||
for (const asset of assets.value) {
|
||||
const promptId = getStackPromptId(asset)
|
||||
items.push({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
})
|
||||
|
||||
if (!promptId || !expandedStackPromptIds.value.has(promptId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const children = stackChildrenByPromptId.value[promptId] ?? []
|
||||
for (const child of children) {
|
||||
items.push({
|
||||
key: `asset-${child.id}`,
|
||||
asset: child,
|
||||
isChild: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const selectableAssets = computed(() =>
|
||||
assetItems.value.map((item) => item.asset)
|
||||
)
|
||||
|
||||
function getStackPromptId(asset: AssetItem): string | null {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
return metadata?.promptId ?? null
|
||||
}
|
||||
|
||||
function isStackExpanded(asset: AssetItem): boolean {
|
||||
const promptId = getStackPromptId(asset)
|
||||
if (!promptId) return false
|
||||
return expandedStackPromptIds.value.has(promptId)
|
||||
}
|
||||
|
||||
async function toggleStack(asset: AssetItem) {
|
||||
const promptId = getStackPromptId(asset)
|
||||
if (!promptId) return
|
||||
|
||||
if (expandedStackPromptIds.value.has(promptId)) {
|
||||
const next = new Set(expandedStackPromptIds.value)
|
||||
next.delete(promptId)
|
||||
expandedStackPromptIds.value = next
|
||||
return
|
||||
}
|
||||
|
||||
if (!stackChildrenByPromptId.value[promptId]?.length) {
|
||||
if (loadingStackPromptIds.value.has(promptId)) {
|
||||
return
|
||||
}
|
||||
const nextLoading = new Set(loadingStackPromptIds.value)
|
||||
nextLoading.add(promptId)
|
||||
loadingStackPromptIds.value = nextLoading
|
||||
|
||||
const children = await resolveStackChildren(asset)
|
||||
|
||||
const afterLoading = new Set(loadingStackPromptIds.value)
|
||||
afterLoading.delete(promptId)
|
||||
loadingStackPromptIds.value = afterLoading
|
||||
|
||||
if (!children.length) {
|
||||
return
|
||||
}
|
||||
|
||||
stackChildrenByPromptId.value = {
|
||||
...stackChildrenByPromptId.value,
|
||||
[promptId]: children
|
||||
}
|
||||
}
|
||||
|
||||
const nextExpanded = new Set(expandedStackPromptIds.value)
|
||||
nextExpanded.add(promptId)
|
||||
expandedStackPromptIds.value = nextExpanded
|
||||
}
|
||||
|
||||
async function resolveStackChildren(asset: AssetItem): Promise<AssetItem[]> {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!metadata) {
|
||||
return []
|
||||
}
|
||||
|
||||
const excludeOutputKey =
|
||||
getOutputKey({
|
||||
nodeId: metadata.nodeId,
|
||||
subfolder: metadata.subfolder,
|
||||
filename: asset.name
|
||||
}) ?? undefined
|
||||
|
||||
try {
|
||||
return await resolveOutputAssetItems(metadata, {
|
||||
createdAt: asset.created_at,
|
||||
excludeOutputKey
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve stack children:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assetItems,
|
||||
selectableAssets,
|
||||
isStackExpanded,
|
||||
toggleStack
|
||||
}
|
||||
}
|
||||
153
src/platform/assets/utils/outputAssetUtil.test.ts
Normal file
153
src/platform/assets/utils/outputAssetUtil.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import { resolveOutputAssetItems } from './outputAssetUtil'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getJobDetail: vi.fn(),
|
||||
getPreviewableOutputsFromJobDetail: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/jobOutputCache', () => ({
|
||||
getJobDetail: mocks.getJobDetail,
|
||||
getPreviewableOutputsFromJobDetail: mocks.getPreviewableOutputsFromJobDetail
|
||||
}))
|
||||
|
||||
type OutputOverrides = Partial<{
|
||||
filename: string
|
||||
subfolder: string
|
||||
nodeId: string
|
||||
url: string
|
||||
}>
|
||||
|
||||
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
|
||||
return {
|
||||
filename: 'file.png',
|
||||
subfolder: 'sub',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/file.png',
|
||||
...overrides
|
||||
} as ResultItemImpl
|
||||
}
|
||||
|
||||
describe('resolveOutputAssetItems', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('maps outputs and excludes a composite output key', async () => {
|
||||
const outputA = createOutput({
|
||||
filename: 'a.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/a.png'
|
||||
})
|
||||
const outputB = createOutput({
|
||||
filename: 'b.png',
|
||||
nodeId: '2',
|
||||
url: 'https://example.com/b.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
executionTimeInSeconds: 12.5,
|
||||
outputCount: 2,
|
||||
allOutputs: [outputA, outputB]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata, {
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
excludeOutputKey: '2-sub-b.png'
|
||||
})
|
||||
|
||||
expect(mocks.getJobDetail).not.toHaveBeenCalled()
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'prompt-1-1-sub-a.png',
|
||||
name: 'a.png',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/a.png'
|
||||
})
|
||||
)
|
||||
expect(results[0].user_metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
executionTimeInSeconds: 12.5
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('loads full outputs when metadata indicates more outputs', async () => {
|
||||
const previewOutput = createOutput({
|
||||
filename: 'preview.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/preview.png'
|
||||
})
|
||||
const fullOutput = createOutput({
|
||||
filename: 'full.png',
|
||||
nodeId: '2',
|
||||
url: 'https://example.com/full.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
promptId: 'prompt-2',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 3,
|
||||
allOutputs: [previewOutput]
|
||||
}
|
||||
const jobDetail = { id: 'job-1' }
|
||||
|
||||
mocks.getJobDetail.mockResolvedValue(jobDetail)
|
||||
mocks.getPreviewableOutputsFromJobDetail.mockReturnValue([
|
||||
fullOutput,
|
||||
previewOutput
|
||||
])
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata)
|
||||
|
||||
expect(mocks.getJobDetail).toHaveBeenCalledWith('prompt-2')
|
||||
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
|
||||
jobDetail
|
||||
)
|
||||
expect(results.map((asset) => asset.name)).toEqual([
|
||||
'full.png',
|
||||
'preview.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps root outputs with empty subfolders', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'root.png',
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
url: 'https://example.com/root.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
promptId: 'prompt-root',
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
outputCount: 1,
|
||||
allOutputs: [output]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata)
|
||||
|
||||
expect(mocks.getJobDetail).not.toHaveBeenCalled()
|
||||
expect(results).toHaveLength(1)
|
||||
const [asset] = results
|
||||
if (!asset) {
|
||||
throw new Error('Expected a root output asset')
|
||||
}
|
||||
expect(asset.id).toBe('prompt-root-1--root.png')
|
||||
if (!asset.user_metadata) {
|
||||
throw new Error('Expected output metadata')
|
||||
}
|
||||
expect(asset.user_metadata.subfolder).toBe('')
|
||||
})
|
||||
})
|
||||
109
src/platform/assets/utils/outputAssetUtil.ts
Normal file
109
src/platform/assets/utils/outputAssetUtil.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
getJobDetail,
|
||||
getPreviewableOutputsFromJobDetail
|
||||
} from '@/services/jobOutputCache'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type OutputAssetMapOptions = {
|
||||
promptId: string
|
||||
outputs: readonly ResultItemImpl[]
|
||||
createdAt?: string
|
||||
executionTimeInSeconds?: number
|
||||
workflow?: OutputAssetMetadata['workflow']
|
||||
excludeOutputKey?: string
|
||||
}
|
||||
|
||||
type ResolveOutputAssetItemsOptions = {
|
||||
createdAt?: string
|
||||
excludeOutputKey?: string
|
||||
}
|
||||
|
||||
type OutputKeyParts = {
|
||||
nodeId?: string | number | null
|
||||
subfolder?: string | null
|
||||
filename?: string | null
|
||||
}
|
||||
|
||||
function shouldLoadFullOutputs(
|
||||
outputCount: OutputAssetMetadata['outputCount'],
|
||||
outputsLength: number
|
||||
): boolean {
|
||||
return (
|
||||
typeof outputCount === 'number' &&
|
||||
outputCount > 1 &&
|
||||
outputsLength < outputCount
|
||||
)
|
||||
}
|
||||
|
||||
export function getOutputKey({
|
||||
nodeId,
|
||||
subfolder,
|
||||
filename
|
||||
}: OutputKeyParts): string | null {
|
||||
if (nodeId == null || subfolder == null || !filename) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${nodeId}-${subfolder}-${filename}`
|
||||
}
|
||||
|
||||
function mapOutputsToAssetItems({
|
||||
promptId,
|
||||
outputs,
|
||||
createdAt,
|
||||
executionTimeInSeconds,
|
||||
workflow,
|
||||
excludeOutputKey
|
||||
}: OutputAssetMapOptions): AssetItem[] {
|
||||
const createdAtValue = createdAt ?? new Date().toISOString()
|
||||
|
||||
return outputs.reduce<AssetItem[]>((items, output) => {
|
||||
const outputKey = getOutputKey(output)
|
||||
if (!output.filename || !outputKey || outputKey === excludeOutputKey) {
|
||||
return items
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: `${promptId}-${outputKey}`,
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: createdAtValue,
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: {
|
||||
promptId,
|
||||
nodeId: output.nodeId,
|
||||
subfolder: output.subfolder,
|
||||
executionTimeInSeconds,
|
||||
workflow
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}, [])
|
||||
}
|
||||
|
||||
export async function resolveOutputAssetItems(
|
||||
metadata: OutputAssetMetadata,
|
||||
{ createdAt, excludeOutputKey }: ResolveOutputAssetItemsOptions = {}
|
||||
): Promise<AssetItem[]> {
|
||||
let outputsToDisplay = metadata.allOutputs ?? []
|
||||
if (shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) {
|
||||
const jobDetail = await getJobDetail(metadata.promptId)
|
||||
const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail)
|
||||
if (previewableOutputs.length) {
|
||||
outputsToDisplay = previewableOutputs
|
||||
}
|
||||
}
|
||||
|
||||
return mapOutputsToAssetItems({
|
||||
promptId: metadata.promptId,
|
||||
outputs: outputsToDisplay,
|
||||
createdAt,
|
||||
executionTimeInSeconds: metadata.executionTimeInSeconds,
|
||||
workflow: metadata.workflow,
|
||||
excludeOutputKey
|
||||
})
|
||||
}
|
||||
@@ -195,6 +195,89 @@ describe('jobOutputCache', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPreviewableOutputsFromJobDetail', () => {
|
||||
it('returns empty array when job detail or outputs are missing', async () => {
|
||||
const { getPreviewableOutputsFromJobDetail } =
|
||||
await import('@/services/jobOutputCache')
|
||||
|
||||
expect(getPreviewableOutputsFromJobDetail(undefined)).toEqual([])
|
||||
|
||||
const jobDetail: JobDetail = {
|
||||
id: 'job-empty',
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
priority: 0
|
||||
}
|
||||
|
||||
expect(getPreviewableOutputsFromJobDetail(jobDetail)).toEqual([])
|
||||
})
|
||||
|
||||
it('maps previewable outputs and skips animated/text entries', async () => {
|
||||
const { getPreviewableOutputsFromJobDetail } =
|
||||
await import('@/services/jobOutputCache')
|
||||
const jobDetail: JobDetail = {
|
||||
id: 'job-previewable',
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
priority: 0,
|
||||
outputs: {
|
||||
'node-1': {
|
||||
images: [
|
||||
{ filename: 'image.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'image.webp', subfolder: '', type: 'temp' }
|
||||
],
|
||||
animated: [true],
|
||||
text: 'hello'
|
||||
},
|
||||
'node-2': {
|
||||
video: [{ filename: 'clip.mp4', subfolder: '', type: 'output' }],
|
||||
audio: [{ filename: 'sound.mp3', subfolder: '', type: 'output' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = getPreviewableOutputsFromJobDetail(jobDetail)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result.map((item) => item.filename).sort()).toEqual(
|
||||
['image.png', 'image.webp', 'clip.mp4', 'sound.mp3'].sort()
|
||||
)
|
||||
|
||||
const image = result.find((item) => item.filename === 'image.png')
|
||||
const video = result.find((item) => item.filename === 'clip.mp4')
|
||||
const { ResultItemImpl: ResultItemImplClass } =
|
||||
await import('@/stores/queueStore')
|
||||
|
||||
expect(image).toBeInstanceOf(ResultItemImplClass)
|
||||
expect(image?.nodeId).toBe('node-1')
|
||||
expect(image?.mediaType).toBe('images')
|
||||
expect(video?.nodeId).toBe('node-2')
|
||||
expect(video?.mediaType).toBe('video')
|
||||
})
|
||||
|
||||
it('filters non-previewable outputs and non-object items', async () => {
|
||||
const { getPreviewableOutputsFromJobDetail } =
|
||||
await import('@/services/jobOutputCache')
|
||||
const jobDetail: JobDetail = {
|
||||
id: 'job-filter',
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
priority: 0,
|
||||
outputs: {
|
||||
'node-3': {
|
||||
images: [{ filename: 'valid.png', subfolder: '', type: 'output' }],
|
||||
text: ['not-object'],
|
||||
unknown: [{ filename: 'data.bin', subfolder: '', type: 'output' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = getPreviewableOutputsFromJobDetail(jobDetail)
|
||||
|
||||
expect(result.map((item) => item.filename)).toEqual(['valid.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getJobDetail', () => {
|
||||
it('fetches and caches job detail', async () => {
|
||||
const jobId = uniqueId('job')
|
||||
|
||||
@@ -11,6 +11,8 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { resultItemType } from '@/schemas/apiSchema'
|
||||
import type { ResultItem, TaskOutput } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
@@ -75,6 +77,75 @@ export async function getOutputsForTask(
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
|
||||
if (!outputs) return []
|
||||
const resultItems = Object.entries(outputs).flatMap(([nodeId, nodeOutputs]) =>
|
||||
Object.entries(nodeOutputs)
|
||||
.filter(([mediaType, _]) => mediaType !== 'animated')
|
||||
.flatMap(([mediaType, items]) => {
|
||||
if (!Array.isArray(items)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return items.filter(isResultItemLike).map(
|
||||
(item) =>
|
||||
new ResultItemImpl({
|
||||
...item,
|
||||
nodeId,
|
||||
mediaType
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
return ResultItemImpl.filterPreviewable(resultItems)
|
||||
}
|
||||
|
||||
function isResultItemLike(item: unknown): item is ResultItem {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = item as Record<string, unknown>
|
||||
|
||||
if (
|
||||
candidate.filename !== undefined &&
|
||||
typeof candidate.filename !== 'string'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.subfolder !== undefined &&
|
||||
typeof candidate.subfolder !== 'string'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.type !== undefined &&
|
||||
!resultItemType.safeParse(candidate.type).success
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.filename === undefined &&
|
||||
candidate.subfolder === undefined &&
|
||||
candidate.type === undefined
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function getPreviewableOutputsFromJobDetail(
|
||||
jobDetail?: JobDetail
|
||||
): ResultItemImpl[] {
|
||||
return getPreviewableOutputs(jobDetail?.outputs)
|
||||
}
|
||||
|
||||
// ===== Job Detail Caching =====
|
||||
|
||||
export async function getJobDetail(
|
||||
|
||||
Reference in New Issue
Block a user