mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-14 11:41:34 +00:00
Compare commits
12 Commits
test/queue
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba22334759 | ||
|
|
191f4128af | ||
|
|
1b8a3fb734 | ||
|
|
7535857276 | ||
|
|
85c6740928 | ||
|
|
235a7e286c | ||
|
|
b32429293f | ||
|
|
5af47b8c01 | ||
|
|
2c694d9fc3 | ||
|
|
0217e061b7 | ||
|
|
b077a658f8 | ||
|
|
b21512303e |
71
docs/adr/0007-node-execution-output-passthrough-schema.md
Normal file
71
docs/adr/0007-node-execution-output-passthrough-schema.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -796,6 +796,7 @@
|
||||
"filterVideo": "Video",
|
||||
"filterAudio": "Audio",
|
||||
"filter3D": "3D",
|
||||
"filterImageCompare": "Image Compare",
|
||||
"filterText": "Text",
|
||||
"viewSettings": "View settings"
|
||||
},
|
||||
@@ -1876,7 +1877,9 @@
|
||||
"imageCompare": {
|
||||
"noImages": "No images to compare",
|
||||
"batchLabelA": "A:",
|
||||
"batchLabelB": "B:"
|
||||
"batchLabelB": "B:",
|
||||
"altBefore": "Before image",
|
||||
"altAfter": "After image"
|
||||
},
|
||||
"batch": {
|
||||
"index": "{current} / {total}"
|
||||
|
||||
124
src/renderer/extensions/linearMode/ImageComparePreview.test.ts
Normal file
124
src/renderer/extensions/linearMode/ImageComparePreview.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import ImageComparePreview from '@/renderer/extensions/linearMode/ImageComparePreview.vue'
|
||||
import type { CompareImages } from '@/stores/queueStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
function makeResultItem(filename: string): ResultItemImpl {
|
||||
return new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
mediaType: 'images',
|
||||
nodeId: '1'
|
||||
})
|
||||
}
|
||||
|
||||
function makeCompareImages(
|
||||
beforeFiles: string[],
|
||||
afterFiles: string[]
|
||||
): CompareImages {
|
||||
return {
|
||||
before: beforeFiles.map(makeResultItem),
|
||||
after: afterFiles.map(makeResultItem)
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(compareImages: CompareImages) {
|
||||
return mount(ImageComparePreview, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'batch.index' && params) {
|
||||
return `${params.current} / ${params.total}`
|
||||
}
|
||||
return key
|
||||
}
|
||||
}
|
||||
},
|
||||
props: { compareImages }
|
||||
})
|
||||
}
|
||||
|
||||
describe('ImageComparePreview', () => {
|
||||
it('renders both before and after images', () => {
|
||||
const compareImages = makeCompareImages(['before.png'], ['after.png'])
|
||||
const wrapper = mountComponent(compareImages)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0].attributes('alt')).toBe('imageCompare.altAfter')
|
||||
expect(images[1].attributes('alt')).toBe('imageCompare.altBefore')
|
||||
})
|
||||
|
||||
it('renders slider handle when both images present', () => {
|
||||
const compareImages = makeCompareImages(['before.png'], ['after.png'])
|
||||
const wrapper = mountComponent(compareImages)
|
||||
|
||||
const handles = wrapper.findAll('[role="presentation"]')
|
||||
expect(handles.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders only before image when no after images', () => {
|
||||
const compareImages = makeCompareImages(['before.png'], [])
|
||||
const wrapper = mountComponent(compareImages)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(1)
|
||||
expect(images[0].attributes('alt')).toBe('imageCompare.altBefore')
|
||||
})
|
||||
|
||||
it('renders only after image when no before images', () => {
|
||||
const compareImages = makeCompareImages([], ['after.png'])
|
||||
const wrapper = mountComponent(compareImages)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(1)
|
||||
expect(images[0].attributes('alt')).toBe('imageCompare.altAfter')
|
||||
})
|
||||
|
||||
it('shows no-images message when both arrays are empty', () => {
|
||||
const compareImages = makeCompareImages([], [])
|
||||
const wrapper = mountComponent(compareImages)
|
||||
|
||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('imageCompare.noImages')
|
||||
})
|
||||
|
||||
it('hides batch nav for single images', () => {
|
||||
const compareImages = makeCompareImages(['before.png'], ['after.png'])
|
||||
const wrapper = mountComponent(compareImages)
|
||||
|
||||
expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows batch nav when multiple images on either side', () => {
|
||||
const compareImages = makeCompareImages(['a1.png', 'a2.png'], ['b1.png'])
|
||||
const wrapper = mountComponent(compareImages)
|
||||
|
||||
expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('navigates before images with batch controls', async () => {
|
||||
const compareImages = makeCompareImages(
|
||||
['a1.png', 'a2.png', 'a3.png'],
|
||||
['b1.png']
|
||||
)
|
||||
const wrapper = mountComponent(compareImages)
|
||||
const beforeBatch = wrapper.find('[data-testid="before-batch"]')
|
||||
|
||||
await beforeBatch.find('[data-testid="batch-next"]').trigger('click')
|
||||
|
||||
expect(beforeBatch.find('[data-testid="batch-counter"]').text()).toBe(
|
||||
'2 / 3'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render slider handle when only one side has images', () => {
|
||||
const compareImages = makeCompareImages(['before.png'], [])
|
||||
const wrapper = mountComponent(compareImages)
|
||||
|
||||
expect(wrapper.find('[role="presentation"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
134
src/renderer/extensions/linearMode/ImageComparePreview.vue
Normal file
134
src/renderer/extensions/linearMode/ImageComparePreview.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import BatchNavigation from '@/renderer/extensions/vueNodes/widgets/components/BatchNavigation.vue'
|
||||
import type { CompareImages } from '@/stores/queueStore'
|
||||
|
||||
const { compareImages } = defineProps<{
|
||||
compareImages: CompareImages
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const sliderPosition = ref(50)
|
||||
const beforeIndex = ref(0)
|
||||
const afterIndex = ref(0)
|
||||
const imageAspect = ref('')
|
||||
|
||||
function onImageLoad(e: Event) {
|
||||
const img = e.target as HTMLImageElement
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
imageAspect.value = `${img.naturalWidth} / ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
const { elementX, elementWidth } = useMouseInElement(containerRef)
|
||||
|
||||
watchEffect(() => {
|
||||
const x = elementX.value
|
||||
const width = elementWidth.value
|
||||
if (width > 0) {
|
||||
sliderPosition.value = Math.max(0, Math.min(100, (x / width) * 100))
|
||||
}
|
||||
})
|
||||
|
||||
const beforeCount = computed(() => compareImages.before.length)
|
||||
const afterCount = computed(() => compareImages.after.length)
|
||||
|
||||
const showBatchNav = computed(
|
||||
() => beforeCount.value > 1 || afterCount.value > 1
|
||||
)
|
||||
|
||||
const beforeUrl = computed(() => {
|
||||
const idx = Math.min(beforeIndex.value, beforeCount.value - 1)
|
||||
return compareImages.before[Math.max(0, idx)]?.url ?? ''
|
||||
})
|
||||
|
||||
const afterUrl = computed(() => {
|
||||
const idx = Math.min(afterIndex.value, afterCount.value - 1)
|
||||
return compareImages.after[Math.max(0, idx)]?.url ?? ''
|
||||
})
|
||||
|
||||
const hasCompareImages = computed(() =>
|
||||
Boolean(beforeUrl.value && afterUrl.value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="showBatchNav"
|
||||
class="flex shrink-0 justify-between px-2 py-1 text-xs"
|
||||
data-testid="batch-nav"
|
||||
>
|
||||
<BatchNavigation
|
||||
v-model="beforeIndex"
|
||||
:count="beforeCount"
|
||||
data-testid="before-batch"
|
||||
>
|
||||
<template #label>{{ $t('imageCompare.batchLabelA') }}</template>
|
||||
</BatchNavigation>
|
||||
<div v-if="beforeCount <= 1" />
|
||||
|
||||
<BatchNavigation
|
||||
v-model="afterIndex"
|
||||
:count="afterCount"
|
||||
data-testid="after-batch"
|
||||
>
|
||||
<template #label>{{ $t('imageCompare.batchLabelB') }}</template>
|
||||
</BatchNavigation>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="beforeUrl || afterUrl"
|
||||
class="flex min-h-0 flex-1 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative h-full max-w-full cursor-col-resize"
|
||||
:style="imageAspect ? { aspectRatio: imageAspect } : undefined"
|
||||
>
|
||||
<img
|
||||
:src="afterUrl || beforeUrl"
|
||||
:alt="
|
||||
afterUrl
|
||||
? $t('imageCompare.altAfter')
|
||||
: $t('imageCompare.altBefore')
|
||||
"
|
||||
draggable="false"
|
||||
class="block size-full"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
|
||||
<img
|
||||
v-if="hasCompareImages"
|
||||
:src="beforeUrl"
|
||||
:alt="$t('imageCompare.altBefore')"
|
||||
draggable="false"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="hasCompareImages"
|
||||
class="pointer-events-none absolute top-0 z-10 h-full w-0.5 -translate-x-1/2 bg-white/80"
|
||||
:style="{ left: `${sliderPosition}%` }"
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
v-if="hasCompareImages"
|
||||
class="pointer-events-none absolute top-1/2 z-10 size-6 -translate-1/2 rounded-full border-2 border-white bg-white/30 shadow-lg backdrop-blur-sm"
|
||||
:style="{ left: `${sliderPosition}%` }"
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"
|
||||
>
|
||||
{{ $t('imageCompare.noImages') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, useAttrs } from 'vue'
|
||||
|
||||
import ImageComparePreview from '@/renderer/extensions/linearMode/ImageComparePreview.vue'
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
@@ -21,8 +22,13 @@ const { output } = defineProps<{
|
||||
const attrs = useAttrs()
|
||||
</script>
|
||||
<template>
|
||||
<ImageComparePreview
|
||||
v-if="getMediaType(output) === 'image_compare' && output.compareImages"
|
||||
:class="cn('flex-1', attrs.class as string)"
|
||||
:compare-images="output.compareImages"
|
||||
/>
|
||||
<ImagePreview
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
v-else-if="getMediaType(output) === 'images'"
|
||||
:class="attrs.class as string"
|
||||
:mobile
|
||||
:src="output.url"
|
||||
|
||||
@@ -71,6 +71,13 @@ const selectableItems = computed(() => {
|
||||
itemId: item.id
|
||||
})
|
||||
}
|
||||
for (const entry of store.activeWorkflowNonAssetOutputs) {
|
||||
items.push({
|
||||
id: `nonasset:${entry.id}`,
|
||||
kind: 'nonAsset',
|
||||
itemId: entry.id
|
||||
})
|
||||
}
|
||||
for (const asset of outputs.media.value) {
|
||||
const outs = allOutputs(asset)
|
||||
for (let k = 0; k < outs.length; k++) {
|
||||
@@ -137,6 +144,16 @@ function doEmit() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (sel.kind === 'nonAsset') {
|
||||
const entry = store.activeWorkflowNonAssetOutputs.find(
|
||||
(e) => e.id === sel.itemId
|
||||
)
|
||||
emit('updateSelection', {
|
||||
output: entry?.output,
|
||||
canShowPreview: true
|
||||
})
|
||||
return
|
||||
}
|
||||
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
|
||||
const output = asset ? allOutputs(asset)[sel.key] : undefined
|
||||
const isFirst = outputs.media.value[0]?.id === sel.assetId
|
||||
@@ -206,6 +223,7 @@ useResizeObserver(outputsRef, () => {
|
||||
watch(
|
||||
[
|
||||
() => store.activeWorkflowInProgressItems.length,
|
||||
() => store.activeWorkflowNonAssetOutputs.length,
|
||||
() => visibleHistory.value[0]?.id,
|
||||
queueCount
|
||||
],
|
||||
@@ -350,11 +368,34 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasActiveContent && visibleHistory.length > 0"
|
||||
v-if="
|
||||
hasActiveContent &&
|
||||
(store.activeWorkflowNonAssetOutputs.length > 0 ||
|
||||
visibleHistory.length > 0)
|
||||
"
|
||||
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="entry in store.activeWorkflowNonAssetOutputs"
|
||||
:key="entry.id"
|
||||
:ref="selectedRef(`nonasset:${entry.id}`)"
|
||||
v-bind="itemAttrs(`nonasset:${entry.id}`)"
|
||||
:class="itemClass"
|
||||
@click="store.select(`nonasset:${entry.id}`)"
|
||||
>
|
||||
<OutputHistoryItem :output="entry.output" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
store.activeWorkflowNonAssetOutputs.length > 0 &&
|
||||
visibleHistory.length > 0
|
||||
"
|
||||
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
||||
/>
|
||||
|
||||
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
|
||||
<div
|
||||
v-if="aIdx > 0"
|
||||
|
||||
55
src/renderer/extensions/linearMode/OutputHistoryItem.test.ts
Normal file
55
src/renderer/extensions/linearMode/OutputHistoryItem.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
|
||||
import type { CompareImages } from '@/stores/queueStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
function makeResultItem(
|
||||
filename: string,
|
||||
mediaType: string,
|
||||
compareImages?: CompareImages
|
||||
): ResultItemImpl {
|
||||
return new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
mediaType,
|
||||
nodeId: '1',
|
||||
compareImages
|
||||
})
|
||||
}
|
||||
|
||||
function mountComponent(output: ResultItemImpl) {
|
||||
return mount(OutputHistoryItem, {
|
||||
props: { output }
|
||||
})
|
||||
}
|
||||
|
||||
describe('OutputHistoryItem', () => {
|
||||
it('renders split 50/50 thumbnail for image_compare items', () => {
|
||||
const before = [makeResultItem('before.png', 'images')]
|
||||
const after = [makeResultItem('after.png', 'images')]
|
||||
const output = makeResultItem('before.png', 'image_compare', {
|
||||
before,
|
||||
after
|
||||
})
|
||||
|
||||
const wrapper = mountComponent(output)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0].attributes('src')).toContain('before.png')
|
||||
expect(images[1].attributes('src')).toContain('after.png')
|
||||
})
|
||||
|
||||
it('renders image thumbnail for regular image items', () => {
|
||||
const output = makeResultItem('photo.png', 'images')
|
||||
|
||||
const wrapper = mountComponent(output)
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toContain('photo.png')
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,27 @@ const { output } = defineProps<{
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="getMediaType(output) === 'image_compare' && output.compareImages"
|
||||
class="relative block size-10 overflow-hidden rounded-sm bg-secondary-background"
|
||||
>
|
||||
<img
|
||||
v-if="output.compareImages.before[0]"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
loading="lazy"
|
||||
:src="output.compareImages.before[0].url"
|
||||
:style="{ clipPath: 'inset(0 50% 0 0)' }"
|
||||
/>
|
||||
<img
|
||||
v-if="output.compareImages.after[0]"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
loading="lazy"
|
||||
:src="output.compareImages.after[0].url"
|
||||
:style="{ clipPath: 'inset(0 0 0 50%)' }"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
v-else-if="getMediaType(output) === 'images'"
|
||||
class="block size-10 rounded-sm bg-secondary-background object-cover"
|
||||
loading="lazy"
|
||||
width="40"
|
||||
|
||||
127
src/renderer/extensions/linearMode/Preview3d.test.ts
Normal file
127
src/renderer/extensions/linearMode/Preview3d.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -10,12 +10,7 @@ function makeOutput(
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('delegates to parseNodeOutput and returns ResultItemImpl instances', () => {
|
||||
const output = makeOutput({
|
||||
images: [
|
||||
{ filename: 'a.png', subfolder: '', type: 'output' },
|
||||
@@ -33,53 +28,25 @@ describe(flattenNodeOutput, () => {
|
||||
expect(result[1].subfolder).toBe('sub')
|
||||
})
|
||||
|
||||
it('flattens audio outputs', () => {
|
||||
const output = makeOutput({
|
||||
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
|
||||
})
|
||||
|
||||
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])
|
||||
|
||||
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])
|
||||
it('returns empty array for text-only output', () => {
|
||||
const result = flattenNodeOutput(['1', makeOutput({ text: 'hello' })])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('combines a_images and b_images into a single image_compare item', () => {
|
||||
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(1)
|
||||
expect(result[0].mediaType).toBe('image_compare')
|
||||
expect(result[0].isImageCompare).toBe(true)
|
||||
expect(result[0].compareImages!.before).toHaveLength(1)
|
||||
expect(result[0].compareImages!.after).toHaveLength(1)
|
||||
expect(result[0].compareImages!.before[0].filename).toBe('before.png')
|
||||
expect(result[0].compareImages!.after[0].filename).toBe('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 { 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)
|
||||
}
|
||||
|
||||
@@ -20,3 +20,4 @@ export interface OutputSelection {
|
||||
export type SelectionValue =
|
||||
| { id: string; kind: 'inProgress'; itemId: string }
|
||||
| { id: string; kind: 'history'; assetId: string; key: number }
|
||||
| { id: string; kind: 'nonAsset'; itemId: string }
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ref } from 'vue'
|
||||
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const activeJobIdRef = ref<string | null>(null)
|
||||
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
|
||||
@@ -64,22 +63,10 @@ vi.mock('@/scripts/api', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
|
||||
flattenNodeOutput: ([nodeId, output]: [
|
||||
string | number,
|
||||
Record<string, unknown>
|
||||
]) => {
|
||||
if (!output.images) return []
|
||||
return (output.images as Array<Record<string, string>>).map(
|
||||
(img) =>
|
||||
new ResultItemImpl({
|
||||
...img,
|
||||
nodeId: String(nodeId),
|
||||
mediaType: 'images'
|
||||
})
|
||||
)
|
||||
}
|
||||
}))
|
||||
vi.mock(
|
||||
'@/renderer/extensions/linearMode/flattenNodeOutput',
|
||||
async (importOriginal) => importOriginal()
|
||||
)
|
||||
|
||||
function setJobWorkflowPath(jobId: string, path: string) {
|
||||
const next = new Map(jobIdToWorkflowPathRef.value)
|
||||
@@ -102,6 +89,23 @@ function makeExecutedDetail(
|
||||
} as ExecutedWsMessage
|
||||
}
|
||||
|
||||
function makeImageCompareDetail(
|
||||
promptId: string,
|
||||
aFilename = 'before.png',
|
||||
bFilename = 'after.png',
|
||||
nodeId = '2'
|
||||
): ExecutedWsMessage {
|
||||
return {
|
||||
prompt_id: promptId,
|
||||
node: nodeId,
|
||||
display_node: nodeId,
|
||||
output: {
|
||||
a_images: [{ filename: aFilename, subfolder: '', type: 'temp' }],
|
||||
b_images: [{ filename: bFilename, subfolder: '', type: 'temp' }]
|
||||
}
|
||||
} as ExecutedWsMessage
|
||||
}
|
||||
|
||||
describe('linearOutputStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -1312,4 +1316,66 @@ describe('linearOutputStore', () => {
|
||||
).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('image compare outputs', () => {
|
||||
it('stores standalone image_compare outputs in activeWorkflowNonAssetOutputs', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
|
||||
expect(store.activeWorkflowNonAssetOutputs[0].output.isImageCompare).toBe(
|
||||
true
|
||||
)
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('separates image_compare to nonAssetOutputs and asset images to pendingResolve', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
// Asset images stay in pendingResolve
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
const remaining = store.inProgressItems.filter((i) => i.jobId === 'job-1')
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0].output?.mediaType).toBe('images')
|
||||
|
||||
// Image compare moved to nonAssetOutputs
|
||||
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
|
||||
expect(store.activeWorkflowNonAssetOutputs[0].output.isImageCompare).toBe(
|
||||
true
|
||||
)
|
||||
expect(store.activeWorkflowNonAssetOutputs[0].jobId).toBe('job-1')
|
||||
})
|
||||
|
||||
it('scopes non-asset outputs to the active workflow', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
||||
setJobWorkflowPath('job-2', 'workflows/app-b.json')
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
store.onJobStart('job-2')
|
||||
store.onNodeExecuted('job-2', makeImageCompareDetail('job-2'))
|
||||
store.onJobComplete('job-2')
|
||||
|
||||
// Active workflow is app-a — only job-1 visible
|
||||
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
|
||||
expect(store.activeWorkflowNonAssetOutputs[0].jobId).toBe('job-1')
|
||||
|
||||
// Switch to app-b — only job-2 visible
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
|
||||
expect(store.activeWorkflowNonAssetOutputs[0].jobId).toBe('job-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
|
||||
const MAX_NON_ASSET_OUTPUTS = 64
|
||||
|
||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const { isAppMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
@@ -20,6 +22,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const inProgressItems = ref<InProgressItem[]>([])
|
||||
const completedNonAssetOutputs = ref<
|
||||
{ id: string; jobId: string; output: ResultItemImpl }[]
|
||||
>([])
|
||||
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
|
||||
const selectedId = ref<string | null>(null)
|
||||
const isFollowing = ref(true)
|
||||
@@ -30,12 +35,19 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const activeWorkflowInProgressItems = computed(() => {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return []
|
||||
const all = inProgressItems.value
|
||||
return all.filter(
|
||||
return inProgressItems.value.filter(
|
||||
(i) => executionStore.jobIdToSessionWorkflowPath.get(i.jobId) === path
|
||||
)
|
||||
})
|
||||
|
||||
const activeWorkflowNonAssetOutputs = computed(() => {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return []
|
||||
return completedNonAssetOutputs.value.filter(
|
||||
(e) => executionStore.jobIdToSessionWorkflowPath.get(e.jobId) === path
|
||||
)
|
||||
})
|
||||
|
||||
let nextSeq = 0
|
||||
|
||||
function makeItemId(jobId: string): string {
|
||||
@@ -153,8 +165,15 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// No skeleton — create image items directly (only for tracked job)
|
||||
if (jobId !== trackedJobId.value) return
|
||||
// No skeleton — create image items directly.
|
||||
// handleExecuted already verified jobId === activeJobId, so start
|
||||
// tracking if we haven't yet (covers nodes that fire before
|
||||
// onJobStart, e.g. ImageCompare with no SaveImage in the workflow).
|
||||
if (!trackedJobId.value) {
|
||||
trackedJobId.value = jobId
|
||||
} else if (jobId !== trackedJobId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const newItems: InProgressItem[] = newOutputs.map((o) => ({
|
||||
id: makeItemId(jobId),
|
||||
@@ -184,14 +203,31 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
trackedJobId.value = null
|
||||
}
|
||||
|
||||
const hasImages = inProgressItems.value.some(
|
||||
const jobImageItems = inProgressItems.value.filter(
|
||||
(i) => i.jobId === jobId && i.state === 'image'
|
||||
)
|
||||
|
||||
if (hasImages) {
|
||||
// Remove non-image items (skeletons, latents), keep images for absorption
|
||||
// Move non-asset outputs (e.g. image_compare) to their own collection
|
||||
// since they won't appear in history.
|
||||
const nonAssetItems = jobImageItems.filter((i) => i.output?.isImageCompare)
|
||||
if (nonAssetItems.length > 0) {
|
||||
completedNonAssetOutputs.value = [
|
||||
...nonAssetItems.map((i) => ({
|
||||
id: i.id,
|
||||
jobId,
|
||||
output: i.output!
|
||||
})),
|
||||
...completedNonAssetOutputs.value
|
||||
].slice(0, MAX_NON_ASSET_OUTPUTS)
|
||||
}
|
||||
|
||||
// Keep only asset images for history absorption, remove everything else.
|
||||
const hasAssetOutputs = jobImageItems.some((i) => !i.output?.isImageCompare)
|
||||
if (hasAssetOutputs) {
|
||||
inProgressItems.value = inProgressItems.value.filter(
|
||||
(i) => i.jobId !== jobId || i.state === 'image'
|
||||
(i) =>
|
||||
i.jobId !== jobId ||
|
||||
(i.state === 'image' && !i.output?.isImageCompare)
|
||||
)
|
||||
pendingResolve.value = new Set([...pendingResolve.value, jobId])
|
||||
} else {
|
||||
@@ -357,6 +393,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
|
||||
return {
|
||||
activeWorkflowInProgressItems,
|
||||
activeWorkflowNonAssetOutputs,
|
||||
resolvedOutputsCache,
|
||||
selectedId,
|
||||
pendingResolve,
|
||||
|
||||
@@ -8,6 +8,10 @@ export const mediaTypes: Record<string, StatItem> = {
|
||||
content: t('sideToolbar.mediaAssets.filter3D'),
|
||||
iconClass: 'icon-[lucide--box]'
|
||||
},
|
||||
image_compare: {
|
||||
content: t('sideToolbar.mediaAssets.filterImageCompare'),
|
||||
iconClass: 'icon-[lucide--columns-2]'
|
||||
},
|
||||
audio: {
|
||||
content: t('sideToolbar.mediaAssets.filterAudio'),
|
||||
iconClass: 'icon-[lucide--audio-lines]'
|
||||
@@ -28,6 +32,7 @@ export const mediaTypes: Record<string, StatItem> = {
|
||||
|
||||
export function getMediaType(output?: ResultItemImpl) {
|
||||
if (!output) return ''
|
||||
if (output.isImageCompare) return 'image_compare'
|
||||
if (output.isVideo) return 'video'
|
||||
if (output.isImage) return 'images'
|
||||
return output.mediaType
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -32,12 +32,18 @@ enum TaskItemDisplayStatus {
|
||||
Cancelled = 'Cancelled'
|
||||
}
|
||||
|
||||
export interface CompareImages {
|
||||
before: readonly ResultItemImpl[]
|
||||
after: readonly ResultItemImpl[]
|
||||
}
|
||||
|
||||
interface ResultItemInit extends ResultItem {
|
||||
nodeId: NodeId
|
||||
mediaType: string
|
||||
format?: string
|
||||
frame_rate?: number
|
||||
display_name?: string
|
||||
compareImages?: CompareImages
|
||||
}
|
||||
|
||||
export class ResultItemImpl {
|
||||
@@ -55,6 +61,8 @@ export class ResultItemImpl {
|
||||
format?: string
|
||||
frame_rate?: number
|
||||
|
||||
compareImages?: CompareImages
|
||||
|
||||
constructor(obj: ResultItemInit) {
|
||||
this.filename = obj.filename ?? ''
|
||||
this.subfolder = obj.subfolder ?? ''
|
||||
@@ -67,6 +75,7 @@ export class ResultItemImpl {
|
||||
|
||||
this.format = obj.format
|
||||
this.frame_rate = obj.frame_rate
|
||||
this.compareImages = obj.compareImages
|
||||
}
|
||||
|
||||
get urlParams(): URLSearchParams {
|
||||
@@ -217,8 +226,18 @@ export class ResultItemImpl {
|
||||
return getMediaTypeFromFilename(this.filename) === '3D'
|
||||
}
|
||||
|
||||
get isImageCompare(): boolean {
|
||||
return this.mediaType === 'image_compare'
|
||||
}
|
||||
|
||||
get supportsPreview(): boolean {
|
||||
return this.isImage || this.isVideo || this.isAudio || this.is3D
|
||||
return (
|
||||
this.isImage ||
|
||||
this.isVideo ||
|
||||
this.isAudio ||
|
||||
this.is3D ||
|
||||
this.isImageCompare
|
||||
)
|
||||
}
|
||||
|
||||
static filterPreviewable(
|
||||
@@ -256,10 +275,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 +283,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) */
|
||||
|
||||
266
src/stores/resultItemParsing.test.ts
Normal file
266
src/stores/resultItemParsing.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
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('image compare outputs', () => {
|
||||
it('produces a single image_compare item from a_images and b_images', () => {
|
||||
const output = {
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = parseNodeOutput('10', output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('image_compare')
|
||||
expect(result[0].nodeId).toBe('10')
|
||||
expect(result[0].filename).toBe('before.png')
|
||||
expect(result[0].compareImages).toBeDefined()
|
||||
expect(result[0].compareImages!.before).toHaveLength(1)
|
||||
expect(result[0].compareImages!.after).toHaveLength(1)
|
||||
expect(result[0].compareImages!.before[0].filename).toBe('before.png')
|
||||
expect(result[0].compareImages!.after[0].filename).toBe('after.png')
|
||||
})
|
||||
|
||||
it('handles multiple batch images in a_images and b_images', () => {
|
||||
const output = {
|
||||
a_images: [
|
||||
{ filename: 'a1.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'a2.png', subfolder: '', type: 'output' }
|
||||
],
|
||||
b_images: [
|
||||
{ filename: 'b1.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'b2.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'b3.png', subfolder: '', type: 'output' }
|
||||
]
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = parseNodeOutput('5', output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].compareImages!.before).toHaveLength(2)
|
||||
expect(result[0].compareImages!.after).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('handles only a_images present', () => {
|
||||
const output = {
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }]
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('image_compare')
|
||||
expect(result[0].compareImages!.before).toHaveLength(1)
|
||||
expect(result[0].compareImages!.after).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles only b_images present', () => {
|
||||
const output = {
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('image_compare')
|
||||
expect(result[0].compareImages!.before).toHaveLength(0)
|
||||
expect(result[0].compareImages!.after).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('after.png')
|
||||
})
|
||||
|
||||
it('includes other output keys alongside image compare', () => {
|
||||
const output = {
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }],
|
||||
images: [{ filename: 'extra.png', subfolder: '', type: 'output' }]
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].mediaType).toBe('image_compare')
|
||||
expect(result[1].mediaType).toBe('images')
|
||||
expect(result[1].filename).toBe('extra.png')
|
||||
})
|
||||
|
||||
it('skips image compare when both a_images and b_images are empty', () => {
|
||||
const output = {
|
||||
a_images: [],
|
||||
b_images: []
|
||||
} as unknown as NodeExecutionOutput
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
92
src/stores/resultItemParsing.ts
Normal file
92
src/stores/resultItemParsing.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
NodeExecutionOutput,
|
||||
ResultItem,
|
||||
ResultItemType
|
||||
} from '@/schemas/apiSchema'
|
||||
import { resultItemType } from '@/schemas/apiSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const METADATA_KEYS = new Set(['animated', 'text'])
|
||||
const EXCLUDED_KEYS = new Set([...METADATA_KEYS, 'a_images', 'b_images'])
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
function toResultItems(
|
||||
items: unknown[],
|
||||
mediaType: string,
|
||||
nodeId: string | number
|
||||
): ResultItemImpl[] {
|
||||
return items
|
||||
.filter(isResultItem)
|
||||
.map((item) => new ResultItemImpl({ ...item, mediaType, nodeId }))
|
||||
}
|
||||
|
||||
function parseImageCompare(
|
||||
nodeOutput: NodeExecutionOutput,
|
||||
nodeId: string | number
|
||||
): ResultItemImpl | null {
|
||||
const aImages = nodeOutput.a_images
|
||||
const bImages = nodeOutput.b_images
|
||||
if (!Array.isArray(aImages) && !Array.isArray(bImages)) return null
|
||||
|
||||
const before = Array.isArray(aImages)
|
||||
? toResultItems(aImages, 'images', nodeId)
|
||||
: []
|
||||
const after = Array.isArray(bImages)
|
||||
? toResultItems(bImages, 'images', nodeId)
|
||||
: []
|
||||
|
||||
if (before.length === 0 && after.length === 0) return null
|
||||
|
||||
const primary = before[0] ?? after[0]
|
||||
return new ResultItemImpl({
|
||||
filename: primary.filename,
|
||||
subfolder: primary.subfolder,
|
||||
type: primary.type as ResultItemType,
|
||||
mediaType: 'image_compare',
|
||||
nodeId,
|
||||
compareImages: { before, after }
|
||||
})
|
||||
}
|
||||
|
||||
export function parseNodeOutput(
|
||||
nodeId: string | number,
|
||||
nodeOutput: NodeExecutionOutput
|
||||
): ResultItemImpl[] {
|
||||
const regularItems = Object.entries(nodeOutput)
|
||||
.filter(([key, value]) => !EXCLUDED_KEYS.has(key) && Array.isArray(value))
|
||||
.flatMap(([mediaType, items]) =>
|
||||
toResultItems(items as unknown[], mediaType, nodeId)
|
||||
)
|
||||
const compareItem = parseImageCompare(nodeOutput, nodeId)
|
||||
return compareItem ? [compareItem, ...regularItems] : regularItems
|
||||
}
|
||||
|
||||
export function parseTaskOutput(
|
||||
taskOutput: Record<string, NodeExecutionOutput>
|
||||
): ResultItemImpl[] {
|
||||
return Object.entries(taskOutput).flatMap(([nodeId, nodeOutput]) =>
|
||||
parseNodeOutput(nodeId, nodeOutput)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user