mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-31 09:45:46 +00:00
Compare commits
3 Commits
coderabbit
...
fix/image-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a724bd9b43 | ||
|
|
45acf75393 | ||
|
|
c6c3e69241 |
@@ -1,85 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
|
||||
function makeOutput(
|
||||
overrides: Partial<NodeExecutionOutput> = {}
|
||||
): NodeExecutionOutput {
|
||||
return { ...overrides }
|
||||
}
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((path: string) => `/api${path}`),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe(flattenNodeOutput, () => {
|
||||
it('returns empty array for output with no known media types', () => {
|
||||
const result = flattenNodeOutput(['1', makeOutput({ text: 'hello' })])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('flattens images into ResultItemImpl instances', () => {
|
||||
const output = makeOutput({
|
||||
images: [
|
||||
{ filename: 'a.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'b.png', subfolder: 'sub', type: 'output' }
|
||||
]
|
||||
})
|
||||
it('delegates to shared parser and returns ResultItemImpl instances', () => {
|
||||
const output: NodeExecutionOutput = {
|
||||
images: [{ filename: 'a.png', subfolder: '', type: 'output' }]
|
||||
}
|
||||
|
||||
const result = flattenNodeOutput(['42', output])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('a.png')
|
||||
expect(result[0].nodeId).toBe('42')
|
||||
expect(result[0].mediaType).toBe('images')
|
||||
expect(result[1].filename).toBe('b.png')
|
||||
expect(result[1].subfolder).toBe('sub')
|
||||
})
|
||||
|
||||
it('flattens audio outputs', () => {
|
||||
const output = makeOutput({
|
||||
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
|
||||
})
|
||||
it('supports non-standard output keys', () => {
|
||||
const output = {
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = flattenNodeOutput([7, output])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('audio')
|
||||
expect(result[0].nodeId).toBe(7)
|
||||
})
|
||||
|
||||
it('flattens multiple media types in a single output', () => {
|
||||
const output = makeOutput({
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
video: [{ filename: 'vid.mp4', subfolder: '', type: 'output' }]
|
||||
})
|
||||
|
||||
const result = flattenNodeOutput(['1', output])
|
||||
const result = flattenNodeOutput(['10', output])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
const types = result.map((r) => r.mediaType)
|
||||
expect(types).toContain('images')
|
||||
expect(types).toContain('video')
|
||||
})
|
||||
|
||||
it('handles gifs and 3d output types', () => {
|
||||
const output = makeOutput({
|
||||
gifs: [
|
||||
{ filename: 'anim.gif', subfolder: '', type: 'output' }
|
||||
] as NodeExecutionOutput['gifs'],
|
||||
'3d': [
|
||||
{ filename: 'model.glb', subfolder: '', type: 'output' }
|
||||
] as NodeExecutionOutput['3d']
|
||||
})
|
||||
|
||||
const result = flattenNodeOutput(['5', output])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
const types = result.map((r) => r.mediaType)
|
||||
expect(types).toContain('gifs')
|
||||
expect(types).toContain('3d')
|
||||
})
|
||||
|
||||
it('ignores empty arrays', () => {
|
||||
const output = makeOutput({ images: [], audio: [] })
|
||||
const result = flattenNodeOutput(['1', output])
|
||||
expect(result).toEqual([])
|
||||
expect(result.map((r) => r.filename)).toContain('before.png')
|
||||
expect(result.map((r) => r.filename)).toContain('after.png')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
import { flattenNodeExecutionOutput } from '@/stores/resultItemParsing'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export function flattenNodeOutput([nodeId, nodeOutput]: [
|
||||
string | number,
|
||||
NodeExecutionOutput
|
||||
]): ResultItemImpl[] {
|
||||
const knownOutputs: Record<string, ResultItem[]> = {}
|
||||
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
|
||||
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
|
||||
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
|
||||
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
|
||||
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
|
||||
|
||||
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
|
||||
outputs.map(
|
||||
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
|
||||
)
|
||||
)
|
||||
return flattenNodeExecutionOutput(nodeId, nodeOutput)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ 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 type { TaskOutput } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { flattenTaskOutputs } from '@/stores/resultItemParsing'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const MAX_TASK_CACHE_SIZE = 50
|
||||
@@ -78,66 +78,7 @@ 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
|
||||
return ResultItemImpl.filterPreviewable(flattenTaskOutputs(outputs))
|
||||
}
|
||||
|
||||
export function getPreviewableOutputsFromJobDetail(
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
StatusWsMessageStatus,
|
||||
TaskOutput
|
||||
} from '@/schemas/apiSchema'
|
||||
import { flattenTaskOutputs } from '@/stores/resultItemParsing'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
@@ -259,21 +260,7 @@ export class TaskItemImpl {
|
||||
}
|
||||
|
||||
calculateFlatOutputs(): ReadonlyArray<ResultItemImpl> {
|
||||
if (!this.outputs) {
|
||||
return []
|
||||
}
|
||||
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
|
||||
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
|
||||
(items as ResultItem[]).map(
|
||||
(item: ResultItem) =>
|
||||
new ResultItemImpl({
|
||||
...item,
|
||||
nodeId,
|
||||
mediaType
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
return flattenTaskOutputs(this.outputs)
|
||||
}
|
||||
|
||||
/** All outputs that support preview (images, videos, audio, 3D) */
|
||||
|
||||
229
src/stores/resultItemParsing.test.ts
Normal file
229
src/stores/resultItemParsing.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeExecutionOutput, TaskOutput } from '@/schemas/apiSchema'
|
||||
import {
|
||||
flattenNodeExecutionOutput,
|
||||
flattenTaskOutputs,
|
||||
isResultItemLike
|
||||
} from '@/stores/resultItemParsing'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((path: string) => `/api${path}`),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe(isResultItemLike, () => {
|
||||
it('accepts valid result items', () => {
|
||||
expect(
|
||||
isResultItemLike({ filename: 'a.png', subfolder: '', type: 'output' })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts items without type', () => {
|
||||
expect(isResultItemLike({ filename: 'a.png', subfolder: '' })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects null/undefined/primitives', () => {
|
||||
expect(isResultItemLike(null)).toBe(false)
|
||||
expect(isResultItemLike(undefined)).toBe(false)
|
||||
expect(isResultItemLike('string')).toBe(false)
|
||||
expect(isResultItemLike(42)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects arrays', () => {
|
||||
expect(isResultItemLike([1, 2, 3])).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects objects missing filename', () => {
|
||||
expect(isResultItemLike({ subfolder: '', type: 'output' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects objects missing subfolder', () => {
|
||||
expect(isResultItemLike({ filename: 'a.png', type: 'output' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects objects with non-string filename', () => {
|
||||
expect(isResultItemLike({ filename: 123, subfolder: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects objects with non-string subfolder', () => {
|
||||
expect(isResultItemLike({ filename: 'a.png', subfolder: 42 })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects objects with invalid type', () => {
|
||||
expect(
|
||||
isResultItemLike({
|
||||
filename: 'a.png',
|
||||
subfolder: '',
|
||||
type: 'invalid_type'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects objects with only type (no filename/subfolder)', () => {
|
||||
expect(isResultItemLike({ type: 'output' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty objects', () => {
|
||||
expect(isResultItemLike({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(flattenNodeExecutionOutput, () => {
|
||||
it('flattens standard image outputs', () => {
|
||||
const output: NodeExecutionOutput = {
|
||||
images: [
|
||||
{ filename: 'a.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'b.png', subfolder: 'sub', type: 'output' }
|
||||
]
|
||||
}
|
||||
|
||||
const result = flattenNodeExecutionOutput('42', output)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].filename).toBe('a.png')
|
||||
expect(result[0].nodeId).toBe('42')
|
||||
expect(result[0].mediaType).toBe('images')
|
||||
expect(result[1].subfolder).toBe('sub')
|
||||
})
|
||||
|
||||
it('flattens non-standard output keys', () => {
|
||||
const output = {
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = flattenNodeExecutionOutput('10', output)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((r) => r.filename)).toContain('before.png')
|
||||
expect(result.map((r) => r.filename)).toContain('after.png')
|
||||
})
|
||||
|
||||
it('flattens multiple media types', () => {
|
||||
const output: NodeExecutionOutput = {
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
video: [{ filename: 'vid.mp4', subfolder: '', type: 'output' }]
|
||||
}
|
||||
|
||||
const result = flattenNodeExecutionOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((r) => r.mediaType)).toContain('images')
|
||||
expect(result.map((r) => r.mediaType)).toContain('video')
|
||||
})
|
||||
|
||||
it('excludes animated key', () => {
|
||||
const output: NodeExecutionOutput = {
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
animated: [true]
|
||||
}
|
||||
|
||||
const result = flattenNodeExecutionOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('images')
|
||||
})
|
||||
|
||||
it('skips non-array values like text strings', () => {
|
||||
const output: NodeExecutionOutput = {
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
text: 'hello'
|
||||
}
|
||||
|
||||
const result = flattenNodeExecutionOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('images')
|
||||
})
|
||||
|
||||
it('filters out non-ResultItem array items', () => {
|
||||
const output = {
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
custom_data: [{ randomKey: 123 }]
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = flattenNodeExecutionOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('images')
|
||||
})
|
||||
|
||||
it('returns empty array for output with no valid media', () => {
|
||||
const result = flattenNodeExecutionOutput('1', { text: 'hello' })
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty arrays', () => {
|
||||
const output: NodeExecutionOutput = { images: [], audio: [] }
|
||||
const result = flattenNodeExecutionOutput('1', output)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts numeric nodeId', () => {
|
||||
const output: NodeExecutionOutput = {
|
||||
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
|
||||
}
|
||||
|
||||
const result = flattenNodeExecutionOutput(7, output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].nodeId).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe(flattenTaskOutputs, () => {
|
||||
it('returns empty array for undefined outputs', () => {
|
||||
expect(flattenTaskOutputs(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('flattens outputs from multiple nodes', () => {
|
||||
const outputs: TaskOutput = {
|
||||
'node-1': {
|
||||
images: [{ filename: 'a.png', subfolder: '', type: 'output' }]
|
||||
},
|
||||
'node-2': {
|
||||
video: [{ filename: 'b.mp4', subfolder: '', type: 'output' }]
|
||||
}
|
||||
}
|
||||
|
||||
const result = flattenTaskOutputs(outputs)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].nodeId).toBe('node-1')
|
||||
expect(result[1].nodeId).toBe('node-2')
|
||||
})
|
||||
|
||||
it('filters animated and non-ResultItem values across nodes', () => {
|
||||
const outputs: TaskOutput = {
|
||||
'node-1': {
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
animated: [true],
|
||||
text: 'hello'
|
||||
}
|
||||
}
|
||||
|
||||
const result = flattenTaskOutputs(outputs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('img.png')
|
||||
})
|
||||
|
||||
it('supports non-standard output keys across nodes', () => {
|
||||
const outputs = {
|
||||
'node-1': {
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
}
|
||||
} as unknown as TaskOutput
|
||||
|
||||
const result = flattenTaskOutputs(outputs)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((r) => r.filename)).toContain('before.png')
|
||||
expect(result.map((r) => r.filename)).toContain('after.png')
|
||||
})
|
||||
})
|
||||
74
src/stores/resultItemParsing.ts
Normal file
74
src/stores/resultItemParsing.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
NodeExecutionOutput,
|
||||
ResultItem,
|
||||
TaskOutput
|
||||
} from '@/schemas/apiSchema'
|
||||
import { resultItemType } from '@/schemas/apiSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const EXCLUDED_KEYS = new Set(['animated'])
|
||||
|
||||
/**
|
||||
* Strict domain guard for result items.
|
||||
*
|
||||
* The wire-format schema (zOutputs) is intentionally permissive via
|
||||
* `.passthrough()` to accept arbitrary keys from custom nodes. This guard
|
||||
* is strict: it requires the fields needed to construct a valid UI model
|
||||
* (ResultItemImpl) that can build preview URLs.
|
||||
*/
|
||||
export 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 (typeof candidate.filename !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof candidate.subfolder !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.type !== undefined &&
|
||||
!resultItemType.safeParse(candidate.type).success
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a single node's execution output into ResultItemImpl instances.
|
||||
*
|
||||
* Iterates all output keys dynamically (to support custom node keys like
|
||||
* `a_images`, `b_images`, `gifs`, etc.) and validates each item with the
|
||||
* strict domain guard before constructing ResultItemImpl.
|
||||
*/
|
||||
export function flattenNodeExecutionOutput(
|
||||
nodeId: string | number,
|
||||
nodeOutput: NodeExecutionOutput
|
||||
): ResultItemImpl[] {
|
||||
return Object.entries(nodeOutput)
|
||||
.filter(([key, value]) => !EXCLUDED_KEYS.has(key) && Array.isArray(value))
|
||||
.flatMap(([mediaType, items]) =>
|
||||
(items as unknown[])
|
||||
.filter(isResultItemLike)
|
||||
.map((item) => new ResultItemImpl({ ...item, mediaType, nodeId }))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens all nodes' outputs from a TaskOutput into ResultItemImpl instances.
|
||||
*/
|
||||
export function flattenTaskOutputs(
|
||||
outputs?: TaskOutput
|
||||
): ReadonlyArray<ResultItemImpl> {
|
||||
if (!outputs) return []
|
||||
return Object.entries(outputs).flatMap(([nodeId, nodeOutput]) =>
|
||||
flattenNodeExecutionOutput(nodeId, nodeOutput)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user