mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 21:21:06 +00:00
Compare commits
3 Commits
version-bu
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
206e07225a | ||
|
|
6342a4378e | ||
|
|
55bd9410eb |
@@ -1920,7 +1920,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)
|
||||
})
|
||||
})
|
||||
142
src/renderer/extensions/linearMode/ImageComparePreview.vue
Normal file
142
src/renderer/extensions/linearMode/ImageComparePreview.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, ref, watch } 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 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}`
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => compareImages,
|
||||
() => {
|
||||
beforeIndex.value = 0
|
||||
afterIndex.value = 0
|
||||
sliderPosition.value = 50
|
||||
imageAspect.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
const sliderPosition = ref(50)
|
||||
|
||||
function updateSlider(e: PointerEvent) {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
sliderPosition.value = Math.max(0, Math.min(100, (x / rect.width) * 100))
|
||||
}
|
||||
|
||||
useEventListener(containerRef, 'pointermove', updateSlider)
|
||||
useEventListener(containerRef, 'pointerleave', updateSlider)
|
||||
|
||||
const showBatchNav = computed(
|
||||
() => compareImages.before.length > 1 || compareImages.after.length > 1
|
||||
)
|
||||
|
||||
const beforeUrl = computed(() => {
|
||||
const idx = Math.min(beforeIndex.value, compareImages.before.length - 1)
|
||||
return compareImages.before[Math.max(0, idx)]?.url ?? ''
|
||||
})
|
||||
|
||||
const afterUrl = computed(() => {
|
||||
const idx = Math.min(afterIndex.value, compareImages.after.length - 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="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
|
||||
v-if="showBatchNav"
|
||||
class="flex shrink-0 justify-between px-4 py-2 text-xs"
|
||||
data-testid="batch-nav"
|
||||
>
|
||||
<BatchNavigation
|
||||
v-model="beforeIndex"
|
||||
:count="compareImages.before.length"
|
||||
data-testid="before-batch"
|
||||
>
|
||||
<template #label>{{ $t('imageCompare.batchLabelA') }}</template>
|
||||
</BatchNavigation>
|
||||
|
||||
<BatchNavigation
|
||||
v-model="afterIndex"
|
||||
:count="compareImages.after.length"
|
||||
data-testid="after-batch"
|
||||
>
|
||||
<template #label>{{ $t('imageCompare.batchLabelB') }}</template>
|
||||
</BatchNavigation>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,7 +5,7 @@ import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import { parseNodeOutput } from '@/stores/resultItemParsing'
|
||||
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -23,7 +23,7 @@ const existingOutput = computed(() => {
|
||||
const locatorId = nodeIdToNodeLocatorId(nodeId)
|
||||
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
|
||||
if (!nodeOutput) continue
|
||||
const results = flattenNodeOutput([nodeId, nodeOutput])
|
||||
const results = parseNodeOutput(nodeId, nodeOutput)
|
||||
if (results.length > 0) return results[0]
|
||||
}
|
||||
return undefined
|
||||
|
||||
@@ -47,6 +47,20 @@ function handleSelection(sel: OutputSelection) {
|
||||
showSkeleton.value = sel.showSkeleton ?? false
|
||||
}
|
||||
|
||||
function downloadOutput(output?: ResultItemImpl) {
|
||||
if (!output) return
|
||||
if (output.compareImages) {
|
||||
for (const img of [
|
||||
...output.compareImages.before,
|
||||
...output.compareImages.after
|
||||
]) {
|
||||
downloadFile(img.url, img.filename)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (output.url) downloadFile(output.url, output.filename)
|
||||
}
|
||||
|
||||
function downloadAsset(item?: AssetItem) {
|
||||
for (const output of allOutputs(item))
|
||||
downloadFile(output.url, output.filename)
|
||||
@@ -93,11 +107,7 @@ async function rerun(e: Event) {
|
||||
v-tooltip.top="t('g.download')"
|
||||
size="icon"
|
||||
:aria-label="t('g.download')"
|
||||
@click="
|
||||
() => {
|
||||
if (selectedOutput?.url) downloadFile(selectedOutput.url)
|
||||
}
|
||||
"
|
||||
@click="() => downloadOutput(selectedOutput)"
|
||||
>
|
||||
<i class="icon-[lucide--download]" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, 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'
|
||||
@@ -25,7 +26,12 @@ const outputLabel = computed(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="mediaType === 'images' || mediaType === 'video'">
|
||||
<ImageComparePreview
|
||||
v-if="mediaType === 'image_compare' && output.compareImages"
|
||||
:class="cn('flex-1', attrs.class as string)"
|
||||
:compare-images="output.compareImages"
|
||||
/>
|
||||
<template v-else-if="mediaType === 'images' || mediaType === 'video'">
|
||||
<ImagePreview
|
||||
v-if="mediaType === 'images'"
|
||||
:class="attrs.class as string"
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
computed,
|
||||
nextTick,
|
||||
ref,
|
||||
toValue,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
watchEffect
|
||||
@@ -31,8 +30,13 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
|
||||
useOutputHistory()
|
||||
const {
|
||||
outputs,
|
||||
allOutputs,
|
||||
timeline,
|
||||
selectFirstHistory,
|
||||
mayBeActiveWorkflowPending
|
||||
} = useOutputHistory()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const queueStore = useQueueStore()
|
||||
const store = useLinearOutputStore()
|
||||
@@ -55,10 +59,6 @@ const hasActiveContent = computed(
|
||||
() => store.activeWorkflowInProgressItems.length > 0
|
||||
)
|
||||
|
||||
const visibleHistory = computed(() =>
|
||||
outputs.media.value.filter((a) => allOutputs(a).length > 0)
|
||||
)
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
const items: SelectionValue[] = []
|
||||
if (mayBeActiveWorkflowPending.value) {
|
||||
@@ -71,16 +71,8 @@ const selectableItems = computed(() => {
|
||||
itemId: item.id
|
||||
})
|
||||
}
|
||||
for (const asset of outputs.media.value) {
|
||||
const outs = allOutputs(asset)
|
||||
for (let k = 0; k < outs.length; k++) {
|
||||
items.push({
|
||||
id: `history:${asset.id}:${k}`,
|
||||
kind: 'history',
|
||||
assetId: asset.id,
|
||||
key: k
|
||||
})
|
||||
}
|
||||
for (const entry of timeline.value) {
|
||||
items.push(entry.selectionValue)
|
||||
}
|
||||
return items
|
||||
})
|
||||
@@ -137,6 +129,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
|
||||
@@ -184,14 +186,24 @@ watch(
|
||||
? selectionMap.value.get(store.selectedId)
|
||||
: undefined
|
||||
|
||||
if (!sv || sv.kind !== 'history') {
|
||||
if (!sv || (sv.kind !== 'history' && sv.kind !== 'nonAsset')) {
|
||||
if (hasOutputs.value) selectFirstHistory()
|
||||
return
|
||||
}
|
||||
|
||||
// Non-asset selections are stable — don't override them
|
||||
if (sv.kind === 'nonAsset') return
|
||||
|
||||
const assetGone = !newAssets.some((a) => a.id === sv.assetId)
|
||||
if (assetGone) {
|
||||
if (hasOutputs.value) selectFirstHistory()
|
||||
return
|
||||
}
|
||||
|
||||
const wasFirst = sv.assetId === oldAssets[0]?.id
|
||||
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
|
||||
if (hasOutputs.value) selectFirstHistory()
|
||||
if (wasFirst && hasOutputs.value) {
|
||||
const firstId = `history:${newAssets[0].id}:0`
|
||||
store.autoSelectLatest(firstId)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -204,7 +216,11 @@ useResizeObserver(outputsRef, () => {
|
||||
lastScrollWidth = outputsRef.value?.scrollWidth ?? 0
|
||||
})
|
||||
watch(
|
||||
() => visibleHistory.value[0]?.id,
|
||||
[
|
||||
() => store.activeWorkflowInProgressItems.length,
|
||||
() => timeline.value[0]?.id,
|
||||
queueCount
|
||||
],
|
||||
() => {
|
||||
const el = outputsRef.value
|
||||
if (!el || el.scrollLeft === 0) {
|
||||
@@ -338,7 +354,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasActiveContent && visibleHistory.length > 0"
|
||||
v-if="hasActiveContent && timeline.length > 0"
|
||||
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
||||
/>
|
||||
</div>
|
||||
@@ -349,20 +365,18 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
class="min-w-0 overflow-x-auto overflow-y-clip"
|
||||
>
|
||||
<div class="flex h-15 w-fit items-start gap-0.5">
|
||||
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
|
||||
<template v-for="(item, idx) in timeline" :key="item.id">
|
||||
<div
|
||||
v-if="aIdx > 0"
|
||||
v-if="idx > 0 && item.groupKey !== timeline[idx - 1].groupKey"
|
||||
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
||||
/>
|
||||
<div
|
||||
v-for="(output, key) in toValue(allOutputs(asset))"
|
||||
:key
|
||||
:ref="selectedRef(`history:${asset.id}:${key}`)"
|
||||
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
|
||||
:ref="selectedRef(item.id)"
|
||||
v-bind="itemAttrs(item.id)"
|
||||
:class="itemClass"
|
||||
@click="store.select(`history:${asset.id}:${key}`)"
|
||||
@click="store.select(item.id)"
|
||||
>
|
||||
<OutputHistoryItem :output="output" />
|
||||
<OutputHistoryItem :output="item.output" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
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('', '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')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
@@ -11,17 +13,38 @@ import VideoPlayOverlay from '@/platform/assets/components/VideoPlayOverlay.vue'
|
||||
const { output } = defineProps<{
|
||||
output: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const mediaType = computed(() => getMediaType(output))
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="mediaType === '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="mediaType === 'images'"
|
||||
class="block size-10 rounded-sm bg-secondary-background object-cover"
|
||||
loading="lazy"
|
||||
width="40"
|
||||
height="40"
|
||||
:src="output.url"
|
||||
/>
|
||||
<template v-else-if="getMediaType(output) === 'video'">
|
||||
<template v-else-if="mediaType === 'video'">
|
||||
<video
|
||||
class="pointer-events-none block size-10 rounded-sm bg-secondary-background object-cover"
|
||||
preload="metadata"
|
||||
@@ -31,8 +54,5 @@ const { output } = defineProps<{
|
||||
/>
|
||||
<VideoPlayOverlay size="sm" />
|
||||
</template>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(mediaTypes[getMediaType(output)]?.iconClass, 'block size-10')"
|
||||
/>
|
||||
<i v-else :class="cn(mediaTypes[mediaType]?.iconClass, 'block size-10')" />
|
||||
</template>
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
|
||||
function makeOutput(
|
||||
overrides: Partial<NodeExecutionOutput> = {}
|
||||
): NodeExecutionOutput {
|
||||
return { ...overrides }
|
||||
}
|
||||
|
||||
describe(flattenNodeOutput, () => {
|
||||
it('returns empty array for output with no known media types', () => {
|
||||
const result = flattenNodeOutput(['1', makeOutput({ unknown: '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 = flattenNodeOutput(['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 = 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])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('flattens non-standard output keys with ResultItem-like values', () => {
|
||||
const output = makeOutput(
|
||||
fromPartial<NodeExecutionOutput>({
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
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 = fromPartial<NodeExecutionOutput>({
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
custom_data: [{ randomKey: 123 }]
|
||||
})
|
||||
|
||||
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 = fromPartial<NodeExecutionOutput>({
|
||||
images: [
|
||||
{ filename: 'valid.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'no-subfolder.png' }
|
||||
]
|
||||
})
|
||||
|
||||
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 = fromPartial<NodeExecutionOutput>({
|
||||
images: [
|
||||
{ filename: 'valid.png', subfolder: '', type: 'output' },
|
||||
{ subfolder: '', type: 'output' }
|
||||
]
|
||||
})
|
||||
|
||||
const result = flattenNodeOutput(['1', output])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('valid.png')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
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[] {
|
||||
return parseNodeOutput(nodeId, nodeOutput)
|
||||
}
|
||||
@@ -20,3 +20,17 @@ 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 }
|
||||
|
||||
export interface TimelineItem {
|
||||
id: string
|
||||
output: ResultItemImpl
|
||||
groupKey: string
|
||||
selectionValue: SelectionValue
|
||||
}
|
||||
|
||||
export interface NonAssetEntry {
|
||||
id: string
|
||||
jobId: string
|
||||
output: ResultItemImpl
|
||||
}
|
||||
|
||||
@@ -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,23 +63,6 @@ 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'
|
||||
})
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
function setJobWorkflowPath(jobId: string, path: string) {
|
||||
const next = new Map(jobIdToWorkflowPathRef.value)
|
||||
next.set(jobId, path)
|
||||
@@ -102,6 +84,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())
|
||||
@@ -1404,4 +1403,168 @@ describe('linearOutputStore', () => {
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoSelectLatest', () => {
|
||||
it('does not override selection when user is browsing', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
|
||||
// Run 1 completes, auto-selects latest
|
||||
store.selectAsLatest('nonasset:compare-1')
|
||||
|
||||
// User manually selects a different item (browsing)
|
||||
store.select('history:run1-asset:1')
|
||||
expect(store.selectedId).toBe('history:run1-asset:1')
|
||||
|
||||
// Run 2 completes, new history appears — media watcher would
|
||||
// call autoSelectLatest to follow the latest history item
|
||||
store.autoSelectLatest('history:run2-asset:0')
|
||||
|
||||
// Should NOT have changed — user was browsing
|
||||
expect(store.selectedId).toBe('history:run1-asset:1')
|
||||
})
|
||||
|
||||
it('updates selection when user is following latest', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
store.selectAsLatest('history:asset-old:0')
|
||||
|
||||
// New history arrives — autoSelectLatest called
|
||||
store.autoSelectLatest('history:asset-new:0')
|
||||
|
||||
// Should update — user was following
|
||||
expect(store.selectedId).toBe('history:asset-new:0')
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-selects new run when viewing the latest non-asset output', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
|
||||
// Run 1 produces a compare output
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
// User clicks the (latest) compare output
|
||||
const latest = store.activeWorkflowNonAssetOutputs[0]
|
||||
store.select(`nonasset:${latest.id}`)
|
||||
|
||||
// New run starts
|
||||
store.onJobStart('job-2')
|
||||
|
||||
// Should auto-select — the selected nonasset is the latest item
|
||||
expect(store.selectedId?.startsWith('slot:')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not auto-select new run when viewing the latest non-asset output if browsing history before', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
|
||||
// Run 1 produces assets + compare
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
// User selects a history asset (browsing, isFollowing=false)
|
||||
store.select('history:asset-1:0')
|
||||
|
||||
// New run starts — should NOT auto-select
|
||||
store.onJobStart('job-2')
|
||||
expect(store.selectedId).toBe('history:asset-1:0')
|
||||
})
|
||||
|
||||
it('does not auto-select new run when viewing an older non-asset output', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
setJobWorkflowPath('job-3', 'workflows/test-workflow.json')
|
||||
|
||||
// Two compare outputs exist (job-2 is latest, job-1 is older)
|
||||
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')
|
||||
|
||||
// User clicks the OLDER compare output (browsing)
|
||||
const olderEntry = store.activeWorkflowNonAssetOutputs.find(
|
||||
(e) => e.jobId === 'job-1'
|
||||
)!
|
||||
store.select(`nonasset:${olderEntry.id}`)
|
||||
|
||||
// New run starts
|
||||
store.onJobStart('job-3')
|
||||
|
||||
// Should NOT auto-select — user was browsing an older item
|
||||
expect(store.selectedId).toBe(`nonasset:${olderEntry.id}`)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,11 @@ import { computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import { parseNodeOutput } from '@/stores/resultItemParsing'
|
||||
import type {
|
||||
InProgressItem,
|
||||
NonAssetEntry
|
||||
} from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -12,6 +15,8 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
|
||||
const MAX_NON_ASSET_OUTPUTS = 100
|
||||
|
||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const { isAppMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
@@ -20,6 +25,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const inProgressItems = ref<InProgressItem[]>([])
|
||||
const completedNonAssetOutputs = shallowRef<NonAssetEntry[]>([])
|
||||
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
|
||||
const selectedId = ref<string | null>(null)
|
||||
const isFollowing = ref(true)
|
||||
@@ -30,12 +36,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 {
|
||||
@@ -114,7 +127,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
const newOutputs = flattenNodeOutput([nodeId, detail.output])
|
||||
const newOutputs = parseNodeOutput(nodeId, detail.output)
|
||||
if (newOutputs.length === 0) return
|
||||
|
||||
// Skip output items for nodes not flagged as output nodes
|
||||
@@ -153,8 +166,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 +204,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 {
|
||||
@@ -234,6 +271,11 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
function autoSelectLatest(id: string | null) {
|
||||
if (!isFollowing.value) return
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
function isJobForActiveWorkflow(jobId: string): boolean {
|
||||
return (
|
||||
executionStore.jobIdToSessionWorkflowPath.get(jobId) ===
|
||||
@@ -246,7 +288,16 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
if (!isJobForActiveWorkflow(jobId)) return
|
||||
|
||||
const sel = selectedId.value
|
||||
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
|
||||
const isLatestNonAsset =
|
||||
sel?.startsWith('nonasset:') &&
|
||||
activeWorkflowNonAssetOutputs.value[0] &&
|
||||
sel === `nonasset:${activeWorkflowNonAssetOutputs.value[0].id}`
|
||||
if (
|
||||
!sel ||
|
||||
sel.startsWith('slot:') ||
|
||||
isLatestNonAsset ||
|
||||
isFollowing.value
|
||||
) {
|
||||
selectedId.value = slotId
|
||||
isFollowing.value = true
|
||||
return
|
||||
@@ -367,11 +418,13 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
|
||||
return {
|
||||
activeWorkflowInProgressItems,
|
||||
activeWorkflowNonAssetOutputs,
|
||||
resolvedOutputsCache,
|
||||
selectedId,
|
||||
pendingResolve,
|
||||
select,
|
||||
selectAsLatest,
|
||||
autoSelectLatest,
|
||||
resolveIfReady,
|
||||
inProgressItems,
|
||||
onJobStart,
|
||||
|
||||
@@ -4,7 +4,10 @@ import { nextTick, ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import {
|
||||
buildTimeline,
|
||||
useOutputHistory
|
||||
} from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
@@ -16,6 +19,9 @@ const selectedIdRef = ref<string | null>(null)
|
||||
const activeWorkflowPathRef = ref<string>('workflows/test.json')
|
||||
const jobIdToPathRef = ref(new Map<string, string>())
|
||||
const isActiveWorkflowRunningRef = ref(false)
|
||||
const activeWorkflowNonAssetOutputsRef = ref<
|
||||
{ id: string; jobId: string; output: ResultItemImpl }[]
|
||||
>([])
|
||||
const runningTasksRef = ref<Array<{ jobId: string }>>([])
|
||||
const pendingTasksRef = ref<Array<{ jobId: string }>>([])
|
||||
|
||||
@@ -47,6 +53,9 @@ vi.mock('@/renderer/extensions/linearMode/linearOutputStore', () => ({
|
||||
get activeWorkflowInProgressItems() {
|
||||
return activeWorkflowInProgressItemsRef.value
|
||||
},
|
||||
get activeWorkflowNonAssetOutputs() {
|
||||
return activeWorkflowNonAssetOutputsRef.value
|
||||
},
|
||||
get selectedId() {
|
||||
return selectedIdRef.value
|
||||
},
|
||||
@@ -98,11 +107,11 @@ vi.mock('@/services/jobOutputCache', () => ({
|
||||
Promise.resolve(jobDetailResults.get(jobId) ?? undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
|
||||
flattenNodeOutput: ([nodeId, output]: [
|
||||
string | number,
|
||||
Record<string, unknown>
|
||||
]) => {
|
||||
vi.mock('@/stores/resultItemParsing', () => ({
|
||||
parseNodeOutput: (
|
||||
nodeId: string | number,
|
||||
output: Record<string, unknown>
|
||||
) => {
|
||||
if (!output.images) return []
|
||||
return (output.images as Array<Record<string, string>>).map(
|
||||
(img) =>
|
||||
@@ -154,6 +163,7 @@ describe(useOutputHistory, () => {
|
||||
pendingResolveRef.value = new Set()
|
||||
inProgressItemsRef.value = []
|
||||
activeWorkflowInProgressItemsRef.value = []
|
||||
activeWorkflowNonAssetOutputsRef.value = []
|
||||
selectedIdRef.value = null
|
||||
activeWorkflowPathRef.value = 'workflows/test.json'
|
||||
jobIdToPathRef.value = new Map()
|
||||
@@ -392,6 +402,35 @@ describe(useOutputHistory, () => {
|
||||
|
||||
expect(resolveIfReadyFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects non-asset output from resolved job instead of first history', async () => {
|
||||
useAppModeStore().selectedOutputs.push('1')
|
||||
const results = [makeResult('a.png'), makeResult('b.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 2
|
||||
})
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
pendingResolveRef.value = new Set(['job-1'])
|
||||
mediaRef.value = [asset]
|
||||
selectedIdRef.value = null
|
||||
|
||||
// Job also produced a compare output now in nonAssetOutputs
|
||||
activeWorkflowNonAssetOutputsRef.value = [
|
||||
{
|
||||
id: 'compare-1',
|
||||
jobId: 'job-1',
|
||||
output: makeResult('compare.png', '2')
|
||||
}
|
||||
]
|
||||
|
||||
useOutputHistory()
|
||||
await nextTick()
|
||||
|
||||
expect(resolveIfReadyFn).toHaveBeenCalledWith('job-1', true)
|
||||
// Should select the non-asset output, not the history asset
|
||||
expect(selectAsLatestFn).toHaveBeenCalledWith('nonasset:compare-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectFirstHistory', () => {
|
||||
@@ -463,3 +502,62 @@ describe(useOutputHistory, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(buildTimeline, () => {
|
||||
function makeAssetWithJobId(id: string, jobId: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
name: `${id}.png`,
|
||||
tags: [],
|
||||
preview_url: `/view?filename=${id}.png`,
|
||||
user_metadata: { jobId, nodeId: '1', subfolder: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function makeOutput(filename: string): ResultItemImpl {
|
||||
return new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
})
|
||||
}
|
||||
|
||||
it('returns empty for no history and no non-asset outputs', () => {
|
||||
expect(buildTimeline([], [], () => [])).toEqual([])
|
||||
})
|
||||
|
||||
it('places orphan non-asset outputs first', () => {
|
||||
const entry = { id: 'c1', jobId: 'job-1', output: makeOutput('cmp.png') }
|
||||
const items = buildTimeline([], [entry], () => [])
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].id).toBe('nonasset:c1')
|
||||
expect(items[0].groupKey).toBe('orphans')
|
||||
})
|
||||
|
||||
it('pairs non-asset outputs with matching history by jobId', () => {
|
||||
const asset = makeAssetWithJobId('a1', 'job-1')
|
||||
const entry = { id: 'c1', jobId: 'job-1', output: makeOutput('cmp.png') }
|
||||
const historyOutput = makeOutput('saved.png')
|
||||
|
||||
const items = buildTimeline([asset], [entry], () => [historyOutput])
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0].id).toBe('nonasset:c1')
|
||||
expect(items[0].groupKey).toBe('a1')
|
||||
expect(items[1].id).toBe('history:a1:0')
|
||||
expect(items[1].groupKey).toBe('a1')
|
||||
})
|
||||
|
||||
it('keeps non-asset outputs as orphans when jobId has no history match', () => {
|
||||
const asset = makeAssetWithJobId('a1', 'job-1')
|
||||
const entry = { id: 'c1', jobId: 'job-2', output: makeOutput('cmp.png') }
|
||||
|
||||
const items = buildTimeline([asset], [entry], () => [makeOutput('s.png')])
|
||||
|
||||
expect(items[0].groupKey).toBe('orphans')
|
||||
expect(items[1].groupKey).toBe('a1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,11 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import { parseNodeOutput } from '@/stores/resultItemParsing'
|
||||
import type {
|
||||
NonAssetEntry,
|
||||
TimelineItem
|
||||
} from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -17,9 +21,83 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
function makeNonAssetItem(
|
||||
entry: NonAssetEntry,
|
||||
groupKey: string
|
||||
): TimelineItem {
|
||||
return {
|
||||
id: `nonasset:${entry.id}`,
|
||||
output: entry.output,
|
||||
groupKey,
|
||||
selectionValue: {
|
||||
id: `nonasset:${entry.id}`,
|
||||
kind: 'nonAsset',
|
||||
itemId: entry.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function indexByJobId(entries: NonAssetEntry[]): Map<string, NonAssetEntry[]> {
|
||||
const map = new Map<string, NonAssetEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const list = map.get(entry.jobId) ?? []
|
||||
list.push(entry)
|
||||
map.set(entry.jobId, list)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function buildTimeline(
|
||||
visibleHistory: AssetItem[],
|
||||
nonAssetOutputs: NonAssetEntry[],
|
||||
allOutputsFn: (asset: AssetItem) => ResultItemImpl[]
|
||||
): TimelineItem[] {
|
||||
const items: TimelineItem[] = []
|
||||
const nonAssetByJobId = indexByJobId(nonAssetOutputs)
|
||||
|
||||
// Collect jobIds that have a matching history entry
|
||||
const pairedJobIds = new Set<string>()
|
||||
for (const asset of visibleHistory) {
|
||||
const m = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (m?.jobId && nonAssetByJobId.has(m.jobId)) pairedJobIds.add(m.jobId)
|
||||
}
|
||||
|
||||
// Orphan compare outputs whose job has no loaded history entry yet
|
||||
for (const entry of nonAssetOutputs) {
|
||||
if (pairedJobIds.has(entry.jobId)) continue
|
||||
items.push(makeNonAssetItem(entry, 'orphans'))
|
||||
}
|
||||
|
||||
// History groups with compare outputs placed before their saved outputs
|
||||
for (const asset of visibleHistory) {
|
||||
const m = getOutputAssetMetadata(asset.user_metadata)
|
||||
const siblings = m?.jobId ? (nonAssetByJobId.get(m.jobId) ?? []) : []
|
||||
for (const entry of siblings) {
|
||||
items.push(makeNonAssetItem(entry, asset.id))
|
||||
}
|
||||
const outs = allOutputsFn(asset)
|
||||
for (let k = 0; k < outs.length; k++) {
|
||||
items.push({
|
||||
id: `history:${asset.id}:${k}`,
|
||||
output: outs[k],
|
||||
groupKey: asset.id,
|
||||
selectionValue: {
|
||||
id: `history:${asset.id}:${k}`,
|
||||
kind: 'history',
|
||||
assetId: asset.id,
|
||||
key: k
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function useOutputHistory(): {
|
||||
outputs: IAssetsProvider
|
||||
allOutputs: (item?: AssetItem) => ResultItemImpl[]
|
||||
timeline: ComputedRef<TimelineItem[]>
|
||||
selectFirstHistory: () => void
|
||||
mayBeActiveWorkflowPending: ComputedRef<boolean>
|
||||
isWorkflowActive: ComputedRef<boolean>
|
||||
@@ -139,7 +217,7 @@ export function useOutputHistory(): {
|
||||
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
||||
if (!jobDetail?.outputs) return []
|
||||
const results = Object.entries(jobDetail.outputs)
|
||||
.flatMap(flattenNodeOutput)
|
||||
.flatMap(([nodeId, output]) => parseNodeOutput(nodeId, output))
|
||||
.toReversed()
|
||||
resolvedCache.set(itemId, results)
|
||||
return results
|
||||
@@ -150,6 +228,18 @@ export function useOutputHistory(): {
|
||||
return filterByOutputNodes(outputRef.value)
|
||||
}
|
||||
|
||||
const visibleHistory = computed(() =>
|
||||
outputs.media.value.filter((a) => allOutputs(a).length > 0)
|
||||
)
|
||||
|
||||
const timeline = computed(() =>
|
||||
buildTimeline(
|
||||
visibleHistory.value,
|
||||
linearStore.activeWorkflowNonAssetOutputs,
|
||||
allOutputs
|
||||
)
|
||||
)
|
||||
|
||||
function selectFirstHistory() {
|
||||
const first = outputs.media.value[0]
|
||||
if (first) {
|
||||
@@ -171,7 +261,16 @@ export function useOutputHistory(): {
|
||||
const loaded = allOutputs(asset).length > 0
|
||||
if (loaded) {
|
||||
linearStore.resolveIfReady(jobId, true)
|
||||
if (!linearStore.selectedId) selectFirstHistory()
|
||||
if (!linearStore.selectedId) {
|
||||
const nonAsset = linearStore.activeWorkflowNonAssetOutputs.find(
|
||||
(e) => e.jobId === jobId
|
||||
)
|
||||
if (nonAsset) {
|
||||
linearStore.selectAsLatest(`nonasset:${nonAsset.id}`)
|
||||
} else {
|
||||
selectFirstHistory()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -196,6 +295,7 @@ export function useOutputHistory(): {
|
||||
return {
|
||||
outputs,
|
||||
allOutputs,
|
||||
timeline,
|
||||
selectFirstHistory,
|
||||
mayBeActiveWorkflowPending,
|
||||
isWorkflowActive,
|
||||
|
||||
@@ -32,6 +32,11 @@ enum TaskItemDisplayStatus {
|
||||
Cancelled = 'Cancelled'
|
||||
}
|
||||
|
||||
export interface CompareImages {
|
||||
before: readonly ResultItemImpl[]
|
||||
after: readonly ResultItemImpl[]
|
||||
}
|
||||
|
||||
interface ResultItemInit extends ResultItem {
|
||||
nodeId: NodeId
|
||||
mediaType: string
|
||||
@@ -39,6 +44,7 @@ interface ResultItemInit extends ResultItem {
|
||||
frame_rate?: number
|
||||
display_name?: string
|
||||
content?: string
|
||||
compareImages?: CompareImages
|
||||
}
|
||||
|
||||
export class ResultItemImpl {
|
||||
@@ -59,6 +65,8 @@ export class ResultItemImpl {
|
||||
// text specific field
|
||||
content?: string
|
||||
|
||||
compareImages?: CompareImages
|
||||
|
||||
constructor(obj: ResultItemInit) {
|
||||
this.filename = obj.filename ?? ''
|
||||
this.subfolder = obj.subfolder ?? ''
|
||||
@@ -72,6 +80,7 @@ export class ResultItemImpl {
|
||||
this.format = obj.format
|
||||
this.frame_rate = obj.frame_rate
|
||||
this.content = obj.content
|
||||
this.compareImages = obj.compareImages
|
||||
}
|
||||
|
||||
get urlParams(): URLSearchParams {
|
||||
@@ -226,6 +235,10 @@ export class ResultItemImpl {
|
||||
return this.mediaType === 'text'
|
||||
}
|
||||
|
||||
get isImageCompare(): boolean {
|
||||
return this.mediaType === 'image_compare'
|
||||
}
|
||||
|
||||
get supportsPreview(): boolean {
|
||||
return (
|
||||
this.isImage || this.isVideo || this.isAudio || this.is3D || this.isText
|
||||
|
||||
@@ -149,6 +149,85 @@ describe(parseNodeOutput, () => {
|
||||
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 = fromPartial<NodeExecutionOutput>({
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
})
|
||||
|
||||
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('')
|
||||
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 = fromPartial<NodeExecutionOutput>({
|
||||
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' }
|
||||
]
|
||||
})
|
||||
|
||||
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 = fromPartial<NodeExecutionOutput>({
|
||||
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }]
|
||||
})
|
||||
|
||||
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 = fromPartial<NodeExecutionOutput>({
|
||||
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
|
||||
})
|
||||
|
||||
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('')
|
||||
})
|
||||
|
||||
it('skips image compare when both a_images and b_images are empty', () => {
|
||||
const output = fromPartial<NodeExecutionOutput>({
|
||||
a_images: [],
|
||||
b_images: []
|
||||
})
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(parseTaskOutput, () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NodeExecutionOutput, ResultItem } 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(['animated', 'text', 'a_images', 'b_images'])
|
||||
|
||||
/**
|
||||
* Validates that an unknown value is a well-formed ResultItem.
|
||||
@@ -27,17 +27,54 @@ function isResultItem(item: unknown): item is ResultItem {
|
||||
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
|
||||
|
||||
return new ResultItemImpl({
|
||||
filename: '',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
mediaType: 'image_compare',
|
||||
nodeId,
|
||||
compareImages: { before, after }
|
||||
})
|
||||
}
|
||||
|
||||
export function parseNodeOutput(
|
||||
nodeId: string | number,
|
||||
nodeOutput: NodeExecutionOutput
|
||||
): ResultItemImpl[] {
|
||||
return Object.entries(nodeOutput)
|
||||
.filter(([key, value]) => !METADATA_KEYS.has(key) && Array.isArray(value))
|
||||
const regularItems = Object.entries(nodeOutput)
|
||||
.filter(([key, value]) => !EXCLUDED_KEYS.has(key) && Array.isArray(value))
|
||||
.flatMap(([mediaType, items]) =>
|
||||
(items as unknown[])
|
||||
.filter(isResultItem)
|
||||
.map((item) => new ResultItemImpl({ ...item, mediaType, nodeId }))
|
||||
toResultItems(items as unknown[], mediaType, nodeId)
|
||||
)
|
||||
const compareItem = parseImageCompare(nodeOutput, nodeId)
|
||||
return compareItem ? [compareItem, ...regularItems] : regularItems
|
||||
}
|
||||
|
||||
export function parseTaskOutput(
|
||||
|
||||
Reference in New Issue
Block a user