mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 16:59:45 +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:
@@ -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