Compare commits

...

3 Commits

Author SHA1 Message Date
bymyself
a724bd9b43 refactor: centralize NodeExecutionOutput → ResultItemImpl parsing
Extract shared isResultItemLike guard and flattenNodeExecutionOutput/
flattenTaskOutputs into resultItemParsing.ts, replacing three separate
implementations that disagreed on validation strictness:

- flattenNodeOutput.ts: was strict (required filename+subfolder)
- jobOutputCache.ts: was permissive (accepted partial objects)
- queueStore.ts: had no validation (cast blindly to ResultItem[])

The shared guard requires filename and subfolder as strings (strict
domain boundary) while the wire schema (zOutputs) remains permissive
via .passthrough() to accept arbitrary custom node output keys.
2026-03-12 00:08:11 -07:00
bymyself
45acf75393 fix: tighten isResultItemLike to require filename and subfolder strings 2026-03-08 17:51:54 -07:00
bymyself
c6c3e69241 fix: support non-standard output keys in app mode preview
Replace hardcoded allowlist of 5 output keys (images, audio, video,
gifs, 3d) with dynamic iteration over all output entries, validating
each item with isResultItemLike. Nodes like ImageCompare that output
non-standard keys (a_images, b_images) now preview correctly.
2026-03-08 17:46:39 -07:00
6 changed files with 333 additions and 157 deletions

View File

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

View File

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

View File

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

View File

@@ -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) */

View 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')
})
})

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