diff --git a/src/stores/nodeOutputStore.test.ts b/src/stores/nodeOutputStore.test.ts index a8d1785480..0af664f986 100644 --- a/src/stores/nodeOutputStore.test.ts +++ b/src/stores/nodeOutputStore.test.ts @@ -151,6 +151,110 @@ describe('nodeOutputStore restoreOutputs', () => { }) }) +describe('nodeOutputStore input preview preservation', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + app.nodeOutputs = {} + app.nodePreviewImages = {} + }) + + it('should preserve input preview when execution sends empty output', () => { + const store = useNodeOutputStore() + const executionId = '3' + + const inputPreview = createMockOutputs([ + { filename: 'example.png', subfolder: '', type: 'input' } + ]) + store.setNodeOutputsByExecutionId(executionId, inputPreview) + + expect(store.nodeOutputs[executionId]?.images).toHaveLength(1) + + const emptyExecutionOutput = createMockOutputs() + store.setNodeOutputsByExecutionId(executionId, emptyExecutionOutput) + + expect(store.nodeOutputs[executionId]?.images).toHaveLength(1) + expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe( + 'example.png' + ) + }) + + it('should preserve input preview when execution sends output with empty images array', () => { + const store = useNodeOutputStore() + const executionId = '3' + + const inputPreview = createMockOutputs([ + { filename: 'example.png', subfolder: '', type: 'input' } + ]) + store.setNodeOutputsByExecutionId(executionId, inputPreview) + + const emptyImagesOutput = createMockOutputs([]) + store.setNodeOutputsByExecutionId(executionId, emptyImagesOutput) + + expect(store.nodeOutputs[executionId]?.images).toHaveLength(1) + expect(store.nodeOutputs[executionId]?.images?.[0].type).toBe('input') + }) + + it('should allow execution output with images to overwrite input preview', () => { + const store = useNodeOutputStore() + const executionId = '3' + + const inputPreview = createMockOutputs([ + { filename: 'example.png', subfolder: '', type: 'input' } + ]) + store.setNodeOutputsByExecutionId(executionId, inputPreview) + + const executionOutput = createMockOutputs([ + { filename: 'output.png', subfolder: '', type: 'output' } + ]) + store.setNodeOutputsByExecutionId(executionId, executionOutput) + + expect(store.nodeOutputs[executionId]?.images).toHaveLength(1) + expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe( + 'output.png' + ) + }) + + it('should not preserve non-input outputs from being overwritten', () => { + const store = useNodeOutputStore() + const executionId = '4' + + const tempOutput = createMockOutputs([ + { filename: 'temp.png', subfolder: '', type: 'temp' } + ]) + store.setNodeOutputsByExecutionId(executionId, tempOutput) + + const emptyOutput = createMockOutputs() + store.setNodeOutputsByExecutionId(executionId, emptyOutput) + + expect(store.nodeOutputs[executionId]?.images).toBeUndefined() + }) + + it('should pass through non-image fields while preserving input preview images', () => { + const store = useNodeOutputStore() + const executionId = '5' + + const inputPreview = createMockOutputs([ + { filename: 'example.png', subfolder: '', type: 'input' } + ]) + store.setNodeOutputsByExecutionId(executionId, inputPreview) + + const videoOutput: ExecutedWsMessage['output'] = { + video: [{ filename: 'output.mp4', subfolder: '', type: 'output' }] + } + store.setNodeOutputsByExecutionId(executionId, videoOutput) + + expect(store.nodeOutputs[executionId]?.images).toHaveLength(1) + expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe( + 'example.png' + ) + expect(store.nodeOutputs[executionId]?.video).toHaveLength(1) + expect(store.nodeOutputs[executionId]?.video?.[0].filename).toBe( + 'output.mp4' + ) + }) +}) + describe('nodeOutputStore getPreviewParam', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) diff --git a/src/stores/nodeOutputStore.ts b/src/stores/nodeOutputStore.ts index 5d2d7ccaa4..a087421c2c 100644 --- a/src/stores/nodeOutputStore.ts +++ b/src/stores/nodeOutputStore.ts @@ -126,6 +126,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { }) } + /** + * Check if an output contains input-type preview images (from upload widgets). + * These are synthetic previews set by LoadImage/LoadVideo widgets, not + * execution results from the backend. + */ + function isInputPreviewOutput( + output: ExecutedWsMessage['output'] | ResultItem | undefined + ): boolean { + const images = (output as ExecutedWsMessage['output'] | undefined)?.images + return ( + Array.isArray(images) && + images.length > 0 && + images.every((i) => i?.type === 'input') + ) + } + /** * Internal function to set outputs by NodeLocatorId. * Handles the merge logic when needed. @@ -140,6 +156,26 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { // (e.g., two LoadImage nodes selecting the same image) if (outputs == null) return + // Preserve input preview images (from upload widgets) when execution + // sends outputs with no images. Without this guard, execution results + // overwrite the upload widget's preview, causing LoadImage/LoadVideo + // nodes to lose their preview after execution + tab switch. + // Note: intentional preview clears go through setNodeOutputs (widget + // path), not setNodeOutputsByExecutionId, so this guard does not + // interfere with user-initiated clears. + const incomingImages = (outputs as ExecutedWsMessage['output']).images + const hasIncomingImages = + Array.isArray(incomingImages) && incomingImages.length > 0 + if ( + !hasIncomingImages && + isInputPreviewOutput(app.nodeOutputs[nodeLocatorId]) + ) { + outputs = { + ...outputs, + images: app.nodeOutputs[nodeLocatorId].images + } + } + if (options.merge) { const existingOutput = app.nodeOutputs[nodeLocatorId] if (existingOutput && outputs) {