[backport cloud/1.41] fix: 3D asset disappears when switching to image output in app mode (#10231)

Backport of #9622 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10231-backport-cloud-1-41-fix-3D-asset-disappears-when-switching-to-image-output-in-app-mod-3276d73d3650819da889e3c1c24aa254)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Comfy Org PR Bot
2026-03-25 09:24:08 +09:00
committed by GitHub
parent 0fa937205c
commit 2e1977dc7d
12 changed files with 521 additions and 111 deletions

View File

@@ -0,0 +1,71 @@
# 7. NodeExecutionOutput Passthrough Schema Design
Date: 2026-03-11
## Status
Accepted
## Context
`NodeExecutionOutput` represents the output data from a ComfyUI node execution. The backend API is intentionally open-ended: custom nodes can output any key (`gifs`, `3d`, `meshes`, `point_clouds`, etc.) alongside the well-known keys (`images`, `audio`, `video`, `animated`, `text`).
The Zod schema uses `.passthrough()` to allow unknown keys through without validation:
```ts
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional(),
video: z.array(zResultItem).optional(),
animated: z.array(z.boolean()).optional(),
text: z.union([z.string(), z.array(z.string())]).optional()
})
.passthrough()
```
This means unknown keys are typed as `unknown` in TypeScript, requiring runtime validation when iterating over all output entries (e.g., to build a unified media list).
### Why not `.catchall(z.array(zResultItem))`?
`.catchall()` correctly handles this at the Zod runtime level — explicit keys override the catchall, so `animated: [true]` parses fine even when the catchall expects `ResultItem[]`.
However, TypeScript's type inference creates an index signature `[k: string]: ResultItem[]` that **conflicts** with the explicit fields `animated: boolean[]` and `text: string | string[]`. These types don't extend `ResultItem[]`, so TypeScript errors on any assignment.
This is a TypeScript limitation, not a Zod or schema design issue. TypeScript cannot express "index signature applies only to keys not explicitly defined."
### Why not remove `animated` and `text` from the schema?
- `animated` is consumed by `isAnimatedOutput()` in `litegraphUtil.ts` and by `litegraphService.ts` to determine whether to render images as static or animated. Removing it would break typing for the graph editor path.
- `text` is part of the `zExecutedWsMessage` validation pipeline. Removing it from `zOutputs` would cause `.catchall()` to reject `{ text: "hello" }` as invalid (it's not `ResultItem[]`).
- Both are structurally different from media outputs — they are metadata, not file references. Mixing them in the same object is a backend API constraint we cannot change here.
## Decision
1. **Keep `.passthrough()`** on `zOutputs`. It correctly reflects the extensible nature of the backend API.
2. **Use `resultItemType` (the Zod enum) for `type` field validation** in the shared `isResultItem` guard. We cannot use `zResultItem.safeParse()` directly because the Zod schema marks `filename` and `subfolder` as `.optional()` (matching the wire format), but a `ResultItemImpl` needs both fields to construct a valid preview URL. The shared guard requires `filename` and `subfolder` as strings while delegating `type` validation to the Zod enum.
3. **Accept the `unknown[]` cast** when iterating passthrough entries. The cast is honest — passthrough values genuinely are `unknown`, and runtime validation narrows them correctly.
4. **Centralize the `NodeExecutionOutput → ResultItemImpl[]` conversion** into a shared utility (`parseNodeOutput` / `parseTaskOutput` in `src/stores/resultItemParsing.ts`) to eliminate duplicated, inconsistent validation across `flattenNodeOutput.ts`, `jobOutputCache.ts`, and `queueStore.ts`.
## Consequences
### Positive
- Single source of truth for `ResultItem` validation (shared `isResultItem` guard using Zod's `resultItemType` enum)
- Consistent validation strictness across all code paths
- Clear documentation of why `.passthrough()` is intentional, preventing future "fix" attempts
- The `unknown[]` cast is contained to one location
### Negative
- Manual `isResultItem` guard is stricter than `zResultItem` Zod schema (requires `filename` and `subfolder`); if the Zod schema changes, the guard must be updated manually
- The `unknown[]` cast remains necessary — cannot be eliminated without a TypeScript language change or backend API restructuring
## Notes
The backend API's extensible output format is a deliberate design choice for ComfyUI's plugin architecture. Custom nodes define their own output types, and the frontend must handle arbitrary keys gracefully. Any future attempt to make the schema stricter must account for this extensibility requirement.
If TypeScript adds support for "rest index signatures" or "exclusive index signatures" in the future, `.catchall()` could replace `.passthrough()` and the `unknown[]` cast would be eliminated.

View File

@@ -8,13 +8,15 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| --------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
## Creating a New ADR

View File

@@ -0,0 +1,127 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const initializeStandaloneViewer = vi.fn()
const cleanup = vi.fn()
vi.mock('@/composables/useLoad3dViewer', () => ({
useLoad3dViewer: () => ({
initializeStandaloneViewer,
cleanup,
handleMouseEnter: vi.fn(),
handleMouseLeave: vi.fn(),
handleResize: vi.fn(),
handleBackgroundImageUpdate: vi.fn(),
exportModel: vi.fn(),
handleSeek: vi.fn(),
isSplatModel: false,
isPlyModel: false,
hasSkeleton: false,
animations: [],
playing: false,
selectedSpeed: 1,
selectedAnimation: 0,
animationProgress: 0,
animationDuration: 0
})
}))
vi.mock('@/components/load3d/Load3DControls.vue', () => ({
default: { template: '<div />' }
}))
vi.mock('@/components/load3d/controls/AnimationControls.vue', () => ({
default: { template: '<div />' }
}))
describe('Preview3d', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
async function mountPreview3d(
modelUrl = 'http://localhost/view?filename=model.glb'
) {
const wrapper = mount(
(await import('@/renderer/extensions/linearMode/Preview3d.vue')).default,
{ props: { modelUrl } }
)
await nextTick()
await nextTick()
return wrapper
}
it('initializes the viewer on mount', async () => {
const wrapper = await mountPreview3d()
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'http://localhost/view?filename=model.glb'
)
wrapper.unmount()
})
it('cleans up the viewer on unmount', async () => {
const wrapper = await mountPreview3d()
cleanup.mockClear()
wrapper.unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('reinitializes correctly after unmount and remount', async () => {
const url = 'http://localhost/view?filename=model.glb'
const wrapper1 = await mountPreview3d(url)
expect(initializeStandaloneViewer).toHaveBeenCalledTimes(1)
cleanup.mockClear()
wrapper1.unmount()
expect(cleanup).toHaveBeenCalledOnce()
vi.clearAllMocks()
const wrapper2 = await mountPreview3d(url)
expect(initializeStandaloneViewer).toHaveBeenCalledTimes(1)
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
url
)
cleanup.mockClear()
wrapper2.unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('reinitializes when modelUrl changes on existing instance', async () => {
const wrapper = await mountPreview3d(
'http://localhost/view?filename=model-a.glb'
)
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
vi.clearAllMocks()
await wrapper.setProps({
modelUrl: 'http://localhost/view?filename=model-b.glb'
})
await nextTick()
await nextTick()
expect(cleanup).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'http://localhost/view?filename=model-b.glb'
)
wrapper.unmount()
})
})

View File

@@ -13,11 +13,16 @@ const containerRef = useTemplateRef('containerRef')
const viewer = ref(useLoad3dViewer())
watch([containerRef, () => modelUrl], async () => {
if (!containerRef.value || !modelUrl) return
watch(
[containerRef, () => modelUrl],
async () => {
if (!containerRef.value || !modelUrl) return
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
})
viewer.value.cleanup()
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
},
{ flush: 'post' }
)
onUnmounted(() => {
viewer.value.cleanup()

View File

@@ -82,4 +82,71 @@ describe(flattenNodeOutput, () => {
const result = flattenNodeOutput(['1', output])
expect(result).toEqual([])
})
it('flattens non-standard output keys with ResultItem-like values', () => {
const output = makeOutput({
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
} as unknown as Partial<NodeExecutionOutput>)
const result = flattenNodeOutput(['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('excludes animated key', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
animated: [true]
})
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('excludes non-ResultItem array items', () => {
const output = {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
custom_data: [{ randomKey: 123 }]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('accepts items with filename but no subfolder', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ filename: 'no-subfolder.png' }
]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(2)
expect(result[0].filename).toBe('valid.png')
expect(result[1].filename).toBe('no-subfolder.png')
expect(result[1].subfolder).toBe('')
})
it('excludes items missing filename', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ subfolder: '', type: 'output' }
]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.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 { parseNodeOutput } 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 parseNodeOutput(nodeId, nodeOutput)
}

View File

@@ -23,6 +23,8 @@ const zResultItem = z.object({
display_name: z.string().optional()
})
export type ResultItem = z.infer<typeof zResultItem>
// Uses .passthrough() because custom nodes can output arbitrary keys.
// See docs/adr/0007-node-execution-output-passthrough-schema.md
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),

View File

@@ -11,11 +11,11 @@ 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 type { TaskItemImpl } from '@/stores/queueStore'
import { parseTaskOutput } from '@/stores/resultItemParsing'
const MAX_TASK_CACHE_SIZE = 50
const MAX_JOB_DETAIL_CACHE_SIZE = 50
@@ -79,65 +79,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(parseTaskOutput(outputs))
}
export function getPreviewableOutputsFromJobDetail(

View File

@@ -66,7 +66,7 @@ vi.mock('@/scripts/api', () => ({
}))
describe('TaskItemImpl', () => {
it('should remove animated property from outputs during construction', () => {
it('should exclude animated from flatOutputs', () => {
const job = createHistoryJob(0, 'job-id')
const taskItem = new TaskItemImpl(job, {
'node-1': {
@@ -75,11 +75,9 @@ describe('TaskItemImpl', () => {
}
})
// Check that animated property was removed
expect('animated' in taskItem.outputs['node-1']).toBe(false)
expect(taskItem.outputs['node-1'].images).toBeDefined()
expect(taskItem.outputs['node-1'].images?.[0]?.filename).toBe('test.png')
expect(taskItem.flatOutputs).toHaveLength(1)
expect(taskItem.flatOutputs[0].filename).toBe('test.png')
expect(taskItem.flatOutputs[0].mediaType).toBe('images')
})
it('should handle outputs without animated property', () => {
@@ -202,8 +200,7 @@ describe('TaskItemImpl', () => {
const task = new TaskItemImpl(job)
expect(task.flatOutputs).toHaveLength(1)
expect(task.flatOutputs[0].filename).toBe('')
expect(task.flatOutputs).toHaveLength(0)
expect(task.previewableOutputs).toHaveLength(0)
expect(task.previewOutput).toBeUndefined()
})

View File

@@ -1,4 +1,3 @@
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
@@ -16,6 +15,7 @@ import type {
} from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { parseTaskOutput } from '@/stores/resultItemParsing'
import type { ComfyApp } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { getJobDetail } from '@/services/jobOutputCache'
@@ -256,10 +256,7 @@ export class TaskItemImpl {
}
}
: {})
// Remove animated outputs from the outputs object
this.outputs = _.mapValues(effectiveOutputs, (nodeOutputs) =>
_.omit(nodeOutputs, 'animated')
)
this.outputs = effectiveOutputs
this.flatOutputs = flatOutputs ?? this.calculateFlatOutputs()
}
@@ -267,18 +264,7 @@ export class TaskItemImpl {
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 parseTaskOutput(this.outputs)
}
/** All outputs that support preview (images, videos, audio, 3D) */

View File

@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import { parseNodeOutput, parseTaskOutput } from '@/stores/resultItemParsing'
function makeOutput(
overrides: Partial<NodeExecutionOutput> = {}
): NodeExecutionOutput {
return { ...overrides }
}
describe(parseNodeOutput, () => {
it('returns empty array for output with no known media types', () => {
const result = parseNodeOutput('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' }
]
})
const result = parseNodeOutput('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].filename).toBe('b.png')
expect(result[1].subfolder).toBe('sub')
})
it('flattens audio outputs', () => {
const output = makeOutput({
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
})
const result = parseNodeOutput(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 = parseNodeOutput('1', 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 = parseNodeOutput('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 = parseNodeOutput('1', output)
expect(result).toEqual([])
})
it('excludes animated key', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
animated: [true]
})
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('excludes text key', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
text: 'some text output'
})
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('excludes non-ResultItem array items', () => {
const output = {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
custom_data: [{ randomKey: 123 }]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('accepts items with filename but no subfolder', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ filename: 'no-subfolder.png' }
]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(2)
expect(result[0].filename).toBe('valid.png')
expect(result[1].filename).toBe('no-subfolder.png')
expect(result[1].subfolder).toBe('')
})
it('excludes items missing filename', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ subfolder: '', type: 'output' }
]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
})
describe(parseTaskOutput, () => {
it('flattens across multiple nodes', () => {
const taskOutput: Record<string, NodeExecutionOutput> = {
'1': makeOutput({
images: [{ filename: 'a.png', subfolder: '', type: 'output' }]
}),
'2': makeOutput({
audio: [{ filename: 'b.wav', subfolder: '', type: 'output' }]
})
}
const result = parseTaskOutput(taskOutput)
expect(result).toHaveLength(2)
expect(result[0].nodeId).toBe('1')
expect(result[0].filename).toBe('a.png')
expect(result[1].nodeId).toBe('2')
expect(result[1].filename).toBe('b.wav')
})
})

View File

@@ -0,0 +1,49 @@
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
import { resultItemType } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
const METADATA_KEYS = new Set(['animated', 'text'])
/**
* Validates that an unknown value is a well-formed ResultItem.
*
* Requires `filename` (string) since ResultItemImpl needs it for a valid URL.
* `subfolder` is optional here — ResultItemImpl constructor falls back to ''.
*/
function isResultItem(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 (
candidate.type !== undefined &&
!resultItemType.safeParse(candidate.type).success
) {
return false
}
return true
}
export function parseNodeOutput(
nodeId: string | number,
nodeOutput: NodeExecutionOutput
): ResultItemImpl[] {
return Object.entries(nodeOutput)
.filter(([key, value]) => !METADATA_KEYS.has(key) && Array.isArray(value))
.flatMap(([mediaType, items]) =>
(items as unknown[])
.filter(isResultItem)
.map((item) => new ResultItemImpl({ ...item, mediaType, nodeId }))
)
}
export function parseTaskOutput(
taskOutput: Record<string, NodeExecutionOutput>
): ResultItemImpl[] {
return Object.entries(taskOutput).flatMap(([nodeId, nodeOutput]) =>
parseNodeOutput(nodeId, nodeOutput)
)
}