diff --git a/browser_tests/assets/widgets/save_image_and_animated_webp.json b/browser_tests/assets/widgets/save_image_and_animated_webp.json new file mode 100644 index 0000000000..b38b2ea03a --- /dev/null +++ b/browser_tests/assets/widgets/save_image_and_animated_webp.json @@ -0,0 +1,86 @@ +{ + "id": "save-image-and-webm-test", + "revision": 0, + "last_node_id": 12, + "last_link_id": 2, + "nodes": [ + { + "id": 10, + "type": "LoadImage", + "pos": [50, 100], + "size": [315, 314], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [1, 2] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": ["example.png", "image"] + }, + { + "id": 11, + "type": "SaveImage", + "pos": [450, 100], + "size": [210, 270], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [], + "properties": {}, + "widgets_values": ["ComfyUI"] + }, + { + "id": 12, + "type": "SaveWEBM", + "pos": [450, 450], + "size": [210, 368], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [], + "properties": {}, + "widgets_values": ["ComfyUI", "vp9", 6, 32] + } + ], + "links": [ + [1, 10, 0, 11, 0, "IMAGE"], + [2, 10, 0, 12, 0, "IMAGE"] + ], + "groups": [], + "config": {}, + "extra": { + "frontendVersion": "1.17.0", + "ds": { + "offset": [0, 0], + "scale": 1 + } + }, + "version": 0.4 +} diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/dragged-node1-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/dragged-node1-chromium-linux.png index 9bbb027fbd..c1bc40c08d 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/dragged-node1-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/dragged-node1-chromium-linux.png differ diff --git a/browser_tests/tests/saveImageAndWebp.spec.ts b/browser_tests/tests/saveImageAndWebp.spec.ts new file mode 100644 index 0000000000..a4929c5e42 --- /dev/null +++ b/browser_tests/tests/saveImageAndWebp.spec.ts @@ -0,0 +1,42 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe( + 'Save Image and WEBM preview', + { tag: ['@screenshot', '@widget'] }, + () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('Can preview both SaveImage and SaveWEBM outputs', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'widgets/save_image_and_animated_webp' + ) + await comfyPage.vueNodes.waitForNodes() + + await comfyPage.runButton.click() + + const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image') + const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM') + + // Wait for SaveImage to render an img inside .image-preview + await expect(saveImageNode.locator('.image-preview img')).toBeVisible({ + timeout: 30000 + }) + + // Wait for SaveWEBM to render a video inside .video-preview + await expect(saveWebmNode.locator('.video-preview video')).toBeVisible({ + timeout: 30000 + }) + + await expect(comfyPage.page).toHaveScreenshot( + 'save-image-and-webm-preview.png' + ) + }) + } +) diff --git a/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-darwin.png b/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-darwin.png new file mode 100644 index 0000000000..41f05af13c Binary files /dev/null and b/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-darwin.png differ diff --git a/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png b/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png new file mode 100644 index 0000000000..7e6040b6e6 Binary files /dev/null and b/browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png differ diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index aaa2dd01ef..392314bf30 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -250,6 +250,7 @@ import { useExecutionStore } from '@/stores/executionStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' import { isTransparent } from '@/utils/colorUtil' +import { isVideoOutput } from '@/utils/litegraphUtil' import { getLocatorIdFromNodeData, getNodeByLocatorId @@ -663,6 +664,7 @@ const nodeMedia = computed(() => { if (!urls?.length) return undefined const type = + isVideoOutput(newOutputs) || node.previewMediaType === 'video' || (!node.previewMediaType && hasVideoInput.value) ? 'video' diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index 6ed0927b5d..28f5ee0b32 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -56,8 +56,10 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' import { useWidgetStore } from '@/stores/widgetStore' import { normalizeI18nKey } from '@/utils/formatUtil' import { + isAnimatedOutput, isImageNode, isVideoNode, + isVideoOutput, migrateWidgetsValues } from '@/utils/litegraphUtil' import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil' @@ -753,17 +755,9 @@ export const useLitegraphService = () => { if (isNewOutput) this.images = output.images if (isNewOutput || isNewPreview) { - this.animatedImages = output?.animated?.find(Boolean) + this.animatedImages = isAnimatedOutput(output) - const isAnimatedWebp = - this.animatedImages && - output?.images?.some((img) => img.filename?.includes('webp')) - const isAnimatedPng = - this.animatedImages && - output?.images?.some((img) => img.filename?.includes('png')) - const isVideo = - (this.animatedImages && !isAnimatedWebp && !isAnimatedPng) || - isVideoNode(this) + const isVideo = isVideoOutput(output) || isVideoNode(this) if (isVideo) { useNodeVideo(this, callback).showPreview() } else { diff --git a/src/stores/imagePreviewStore.test.ts b/src/stores/imagePreviewStore.test.ts index 7defd072c5..dfabad853a 100644 --- a/src/stores/imagePreviewStore.test.ts +++ b/src/stores/imagePreviewStore.test.ts @@ -9,6 +9,7 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore' import * as litegraphUtil from '@/utils/litegraphUtil' vi.mock('@/utils/litegraphUtil', () => ({ + isAnimatedOutput: vi.fn(), isVideoNode: vi.fn() })) @@ -150,13 +151,14 @@ describe('imagePreviewStore getPreviewParam', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) vi.clearAllMocks() + vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false) vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false) }) - it('should return empty string if node.animatedImages is true', () => { + it('should return empty string if output is animated', () => { const store = useNodeOutputStore() - // @ts-expect-error `animatedImages` property is not typed - const node = createMockNode({ animatedImages: true }) + vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(true) + const node = createMockNode() const outputs = createMockOutputs([{ filename: 'img.png' }]) expect(store.getPreviewParam(node, outputs)).toBe('') expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled() diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index 6919d8ecd0..4b49eb3bfe 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -14,7 +14,7 @@ import { app } from '@/scripts/app' import { useExecutionStore } from '@/stores/executionStore' import type { NodeLocatorId } from '@/types/nodeIdentification' import { parseFilePath } from '@/utils/formatUtil' -import { isVideoNode } from '@/utils/litegraphUtil' +import { isAnimatedOutput, isVideoNode } from '@/utils/litegraphUtil' import { releaseSharedObjectUrl, retainSharedObjectUrl @@ -83,7 +83,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { outputs: ExecutedWsMessage['output'] ): boolean => { // If animated webp/png or video outputs, return false - if (node.animatedImages || isVideoNode(node)) return false + if (isAnimatedOutput(outputs) || isVideoNode(node)) return false // If no images, return false if (!outputs?.images?.length) return false diff --git a/src/utils/litegraphUtil.test.ts b/src/utils/litegraphUtil.test.ts index a96028a3cd..462005b965 100644 --- a/src/utils/litegraphUtil.test.ts +++ b/src/utils/litegraphUtil.test.ts @@ -9,9 +9,12 @@ import type { import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation' import type { IWidget } from '@/lib/litegraph/src/types/widgets' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { ExecutedWsMessage } from '@/schemas/apiSchema' import { compressWidgetInputSlots, createNode, + isAnimatedOutput, + isVideoOutput, migrateWidgetsValues } from '@/utils/litegraphUtil' @@ -199,6 +202,106 @@ describe('migrateWidgetsValues', () => { }) }) +function createOutput( + overrides: Partial = {} +): ExecutedWsMessage['output'] { + return { ...overrides } +} + +describe('isAnimatedOutput', () => { + it('returns false for undefined output', () => { + expect(isAnimatedOutput(undefined)).toBe(false) + }) + + it('returns false when animated array is missing', () => { + expect(isAnimatedOutput(createOutput())).toBe(false) + }) + + it('returns false when all animated values are false', () => { + expect(isAnimatedOutput(createOutput({ animated: [false, false] }))).toBe( + false + ) + }) + + it('returns true when any animated value is true', () => { + expect(isAnimatedOutput(createOutput({ animated: [false, true] }))).toBe( + true + ) + }) +}) + +describe('isVideoOutput', () => { + it('returns false for non-animated output', () => { + expect( + isVideoOutput( + createOutput({ + animated: [false], + images: [{ filename: 'video.webm' }] + }) + ) + ).toBe(false) + }) + + it('returns false for animated webp output', () => { + expect( + isVideoOutput( + createOutput({ + animated: [true], + images: [{ filename: 'anim.webp' }] + }) + ) + ).toBe(false) + }) + + it('returns false for animated png output', () => { + expect( + isVideoOutput( + createOutput({ + animated: [true], + images: [{ filename: 'anim.png' }] + }) + ) + ).toBe(false) + }) + + it('returns true for animated webm output', () => { + expect( + isVideoOutput( + createOutput({ + animated: [true], + images: [{ filename: 'output.webm' }] + }) + ) + ).toBe(true) + }) + + it('returns true for animated mp4 output', () => { + expect( + isVideoOutput( + createOutput({ + animated: [true], + images: [{ filename: 'output.mp4' }] + }) + ) + ).toBe(true) + }) + + it('returns true for animated output with no images array', () => { + expect(isVideoOutput(createOutput({ animated: [true] }))).toBe(true) + }) + + it('does not false-positive on filenames containing webp as substring', () => { + expect( + isVideoOutput( + createOutput({ + animated: [true], + images: [{ filename: 'my_webp_file.mp4' }] + }) + ) + ).toBe(true) + }) +}) + describe('compressWidgetInputSlots', () => { it('should remove unconnected widget input slots', () => { // Using partial mock - only including properties needed for test diff --git a/src/utils/litegraphUtil.ts b/src/utils/litegraphUtil.ts index 28e1818917..51203ac686 100644 --- a/src/utils/litegraphUtil.ts +++ b/src/utils/litegraphUtil.ts @@ -5,6 +5,7 @@ import type { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph' +import type { ExecutedWsMessage } from '@/schemas/apiSchema' import { LGraphGroup, LGraphNode, @@ -77,6 +78,32 @@ export function isVideoNode(node: LGraphNode | undefined): node is VideoNode { return node.previewMediaType === 'video' || !!node.videoContainer } +/** + * Check if output data indicates animated content (animated webp/png or video). + */ +export function isAnimatedOutput( + output: ExecutedWsMessage['output'] | undefined +): boolean { + return !!output?.animated?.find(Boolean) +} + +/** + * Check if output data indicates video content (animated but not webp/png). + */ +export function isVideoOutput( + output: ExecutedWsMessage['output'] | undefined +): boolean { + if (!isAnimatedOutput(output)) return false + + const isAnimatedWebp = output?.images?.some((img) => + img.filename?.endsWith('.webp') + ) + const isAnimatedPng = output?.images?.some((img) => + img.filename?.endsWith('.png') + ) + return !isAnimatedWebp && !isAnimatedPng +} + export function isAudioNode(node: LGraphNode | undefined): boolean { return !!node && node.previewMediaType === 'audio' }