diff --git a/.github/workflows/test-ui.yaml b/.github/workflows/test-ui.yaml index 4a52bf200..fb959d2f5 100644 --- a/.github/workflows/test-ui.yaml +++ b/.github/workflows/test-ui.yaml @@ -30,7 +30,7 @@ jobs: with: repository: 'Comfy-Org/ComfyUI_devtools' path: 'ComfyUI/custom_nodes/ComfyUI_devtools' - ref: '49c8220be49120dbaff85f32813d854d6dff2d05' + ref: '9d2421fd3208a310e4d0f71fca2ea0c985759c33' - uses: actions/setup-node@v4 with: diff --git a/browser_tests/assets/animated_webp.webp b/browser_tests/assets/animated_webp.webp new file mode 100644 index 000000000..b84420912 Binary files /dev/null and b/browser_tests/assets/animated_webp.webp differ diff --git a/browser_tests/assets/widgets/load_animated_webp.json b/browser_tests/assets/widgets/load_animated_webp.json new file mode 100644 index 000000000..561da3147 --- /dev/null +++ b/browser_tests/assets/widgets/load_animated_webp.json @@ -0,0 +1,11 @@ +{ + "8": { + "inputs": { + "image": "animated_web.webp" + }, + "class_type": "DevToolsLoadAnimatedImageTest", + "_meta": { + "title": "Load Animated Image" + } + } +} \ No newline at end of file diff --git a/browser_tests/assets/widgets/save_animated_webp.json b/browser_tests/assets/widgets/save_animated_webp.json new file mode 100644 index 000000000..362a56cec --- /dev/null +++ b/browser_tests/assets/widgets/save_animated_webp.json @@ -0,0 +1,60 @@ +{ + "id": "3f1fcbf9-f9de-4935-8fad-401813f61b13", + "revision": 0, + "last_node_id": 10, + "last_link_id": 4, + "nodes": [ + { + "id": 9, + "type": "SaveAnimatedWEBP", + "pos": [336, 104], + "size": [210, 368], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 4 + } + ], + "outputs": [], + "properties": {}, + "widgets_values": ["ComfyUI", 6, true, 80, "default"] + }, + { + "id": 10, + "type": "DevToolsLoadAnimatedImageTest", + "pos": [64, 104], + "size": [210, 316], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [4] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "DevToolsLoadAnimatedImageTest" + }, + "widgets_values": ["animated_web.webp", "image"] + } + ], + "links": [[4, 10, 0, 9, 0, "IMAGE"]], + "groups": [], + "config": {}, + "extra": { + "frontendVersion": "1.17.0" + }, + "version": 0.4 +} diff --git a/browser_tests/tests/widget.spec.ts b/browser_tests/tests/widget.spec.ts index 1c3e809eb..2478c6b5a 100644 --- a/browser_tests/tests/widget.spec.ts +++ b/browser_tests/tests/widget.spec.ts @@ -186,6 +186,105 @@ test.describe('Image widget', () => { }) }) +test.describe('Animated image widget', () => { + test('Shows preview of uploaded animated image', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('widgets/load_animated_webp') + + // Get position of the load animated webp node + const nodes = await comfyPage.getNodeRefsByType( + 'DevToolsLoadAnimatedImageTest' + ) + const loadAnimatedWebpNode = nodes[0] + const { x, y } = await loadAnimatedWebpNode.getPosition() + + // Drag and drop image file onto the load animated webp node + await comfyPage.dragAndDropFile('animated_webp.webp', { + dropPosition: { x, y } + }) + + // Expect the image preview to change automatically + await expect(comfyPage.canvas).toHaveScreenshot( + 'animated_image_preview_drag_and_dropped.png' + ) + + // Wait for animation to go to next frame + await comfyPage.page.waitForTimeout(512) + + // Move mouse and click on canvas to trigger render + await comfyPage.page.mouse.click(64, 64) + + // Expect the image preview to change to the next frame of the animation + await expect(comfyPage.canvas).toHaveScreenshot( + 'animated_image_preview_drag_and_dropped_next_frame.png' + ) + }) + + test('Can drag-and-drop animated webp image', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('widgets/load_animated_webp') + + // Get position of the load animated webp node + const nodes = await comfyPage.getNodeRefsByType( + 'DevToolsLoadAnimatedImageTest' + ) + const loadAnimatedWebpNode = nodes[0] + const { x, y } = await loadAnimatedWebpNode.getPosition() + + // Drag and drop image file onto the load animated webp node + await comfyPage.dragAndDropFile('animated_webp.webp', { + dropPosition: { x, y } + }) + + // Expect the filename combo value to be updated + const fileComboWidget = await loadAnimatedWebpNode.getWidget(0) + const filename = await fileComboWidget.getValue() + expect(filename).toContain('animated_webp.webp') + }) + + test('Can preview saved animated webp image', async ({ comfyPage }) => { + await comfyPage.loadWorkflow('widgets/save_animated_webp') + + // Get position of the load animated webp node + const loadNodes = await comfyPage.getNodeRefsByType( + 'DevToolsLoadAnimatedImageTest' + ) + const loadAnimatedWebpNode = loadNodes[0] + const { x, y } = await loadAnimatedWebpNode.getPosition() + + // Drag and drop image file onto the load animated webp node + await comfyPage.dragAndDropFile('animated_webp.webp', { + dropPosition: { x, y } + }) + await comfyPage.nextFrame() + + // Get the SaveAnimatedWEBP node + const saveNodes = await comfyPage.getNodeRefsByType('SaveAnimatedWEBP') + const saveAnimatedWebpNode = saveNodes[0] + if (!saveAnimatedWebpNode) + throw new Error('SaveAnimatedWEBP node not found') + + // Simulate the graph executing + await comfyPage.page.evaluate( + ([loadId, saveId]) => { + // Set the output of the SaveAnimatedWEBP node to equal the loader node's image + window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId] + }, + [loadAnimatedWebpNode.id, saveAnimatedWebpNode.id] + ) + await comfyPage.nextFrame() + + // Wait for animation to go to next frame + await comfyPage.page.waitForTimeout(512) + + // Move mouse and click on canvas to trigger render + await comfyPage.page.mouse.click(64, 64) + + // Expect the SaveAnimatedWEBP node to have an output preview + await expect(comfyPage.canvas).toHaveScreenshot( + 'animated_image_preview_saved_webp.png' + ) + }) +}) + test.describe('Load audio widget', () => { test('Can load audio', async ({ comfyPage }) => { await comfyPage.loadWorkflow('widgets/load_audio_widget') diff --git a/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-drag-and-dropped-chromium-linux.png b/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-drag-and-dropped-chromium-linux.png new file mode 100644 index 000000000..a264d017c Binary files /dev/null and b/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-drag-and-dropped-chromium-linux.png differ diff --git a/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-drag-and-dropped-next-frame-chromium-linux.png b/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-drag-and-dropped-next-frame-chromium-linux.png new file mode 100644 index 000000000..aebb26d86 Binary files /dev/null and b/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-drag-and-dropped-next-frame-chromium-linux.png differ diff --git a/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png b/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png new file mode 100644 index 000000000..cdd7d2a9f Binary files /dev/null and b/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png differ diff --git a/src/composables/node/useNodeAnimatedImage.ts b/src/composables/node/useNodeAnimatedImage.ts index 0a4c6fee9..3dabb8e9b 100644 --- a/src/composables/node/useNodeAnimatedImage.ts +++ b/src/composables/node/useNodeAnimatedImage.ts @@ -3,6 +3,7 @@ import type { IWidget } from '@comfyorg/litegraph/dist/types/widgets' import { ANIM_PREVIEW_WIDGET } from '@/scripts/app' import { createImageHost } from '@/scripts/ui/imagePreview' +import { fitDimensionsToNodeWidth } from '@/utils/imageUtil' /** * Composable for handling animated image previews in nodes @@ -42,6 +43,16 @@ export function useNodeAnimatedImage() { widget.serialize = false widget.serializeValue = () => undefined widget.options.host.updateImages(node.imgs) + widget.computeLayoutSize = () => { + const img = widget.options.host.getCurrentImage() + if (!img) return { minHeight: 0, minWidth: 0 } + + return fitDimensionsToNodeWidth( + img.naturalWidth, + img.naturalHeight, + node.size?.[0] || 0 + ) + } } } diff --git a/src/composables/node/useNodeImage.ts b/src/composables/node/useNodeImage.ts index 80fc52f78..750459229 100644 --- a/src/composables/node/useNodeImage.ts +++ b/src/composables/node/useNodeImage.ts @@ -1,6 +1,7 @@ import type { LGraphNode } from '@comfyorg/litegraph' import { useNodeOutputStore } from '@/stores/imagePreviewStore' +import { fitDimensionsToNodeWidth } from '@/utils/imageUtil' const VIDEO_WIDGET_NAME = 'video-preview' const VIDEO_DEFAULT_OPTIONS = { @@ -131,12 +132,15 @@ export const useNodeVideo = (node: LGraphNode) => { let minWidth = DEFAULT_VIDEO_SIZE const setMinDimensions = (video: HTMLVideoElement) => { - const intrinsicAspectRatio = video.videoWidth / video.videoHeight - if (!intrinsicAspectRatio || isNaN(intrinsicAspectRatio)) return + const { minHeight: calculatedHeight, minWidth: calculatedWidth } = + fitDimensionsToNodeWidth( + video.videoWidth, + video.videoHeight, + node.size?.[0] || DEFAULT_VIDEO_SIZE + ) - // Set min. height s.t. video spans node's x-axis while maintaining aspect ratio - minWidth = node.size?.[0] || DEFAULT_VIDEO_SIZE - minHeight = Math.max(minWidth / intrinsicAspectRatio, 64) + minWidth = calculatedWidth + minHeight = calculatedHeight } const loadElement = (url: string): Promise => diff --git a/src/composables/widgets/useImageUploadWidget.ts b/src/composables/widgets/useImageUploadWidget.ts index a8e93fa9c..3f8afeeb7 100644 --- a/src/composables/widgets/useImageUploadWidget.ts +++ b/src/composables/widgets/useImageUploadWidget.ts @@ -37,6 +37,7 @@ export const useImageUploadWidget = () => { const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions const nodeOutputStore = useNodeOutputStore() + const isAnimated = !!inputOptions.animated_image_upload const isVideo = !!inputOptions.video_upload const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node) @@ -92,7 +93,9 @@ export const useImageUploadWidget = () => { // Add our own callback to the combo widget to render an image when it changes fileComboWidget.callback = function () { - nodeOutputStore.setNodeOutputs(node, fileComboWidget.value) + nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, { + isAnimated + }) node.graph?.setDirtyCanvas(true) } @@ -100,7 +103,9 @@ export const useImageUploadWidget = () => { // The value isnt set immediately so we need to wait a moment // No change callbacks seem to be fired on initial setting of the value requestAnimationFrame(() => { - nodeOutputStore.setNodeOutputs(node, fileComboWidget.value) + nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, { + isAnimated + }) showPreview({ block: false }) }) diff --git a/src/extensions/core/uploadImage.ts b/src/extensions/core/uploadImage.ts index 755f9000f..4ed7130ee 100644 --- a/src/extensions/core/uploadImage.ts +++ b/src/extensions/core/uploadImage.ts @@ -14,7 +14,8 @@ const isMediaUploadComboInput = (inputSpec: InputSpec) => { const isUploadInput = inputOptions['image_upload'] === true || - inputOptions['video_upload'] === true + inputOptions['video_upload'] === true || + inputOptions['animated_image_upload'] === true return ( isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO') diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts index 486303698..b9482172e 100644 --- a/src/schemas/nodeDefSchema.ts +++ b/src/schemas/nodeDefSchema.ts @@ -74,6 +74,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({ image_folder: z.enum(['input', 'output', 'temp']).optional(), allow_batch: z.boolean().optional(), video_upload: z.boolean().optional(), + animated_image_upload: z.boolean().optional(), options: z.array(zComboOption).optional(), remote: zRemoteWidgetConfig.optional(), /** Whether the widget is a multi-select widget. */ diff --git a/src/scripts/ui/imagePreview.ts b/src/scripts/ui/imagePreview.ts index 84b70fb3d..0842d5cf8 100644 --- a/src/scripts/ui/imagePreview.ts +++ b/src/scripts/ui/imagePreview.ts @@ -92,6 +92,10 @@ export function createImageHost(node) { } return { el, + getCurrentImage() { + // @ts-expect-error fixme ts strict error + return currentImgs?.[0] + }, // @ts-expect-error fixme ts strict error updateImages(imgs) { // @ts-expect-error fixme ts strict error diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index 880cc1f7f..ce95be3a7 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -8,10 +8,12 @@ import { isVideoNode } from '@/utils/litegraphUtil' const createOutputs = ( filenames: string[], - type: string + type: string, + isAnimated: boolean ): ExecutedWsMessage['output'] => { return { - images: filenames.map((image) => ({ type, ...parseFilePath(image) })) + images: filenames.map((image) => ({ type, ...parseFilePath(image) })), + animated: filenames.map((image) => isAnimated && image.endsWith('.webp')) } } @@ -52,18 +54,21 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { function setNodeOutputs( node: LGraphNode, filenames: string | string[] | ResultItem, - { folder = 'input' }: { folder?: string } = {} + { + folder = 'input', + isAnimated = false + }: { folder?: string; isAnimated?: boolean } = {} ) { if (!filenames || !node) return const nodeId = getNodeId(node) if (typeof filenames === 'string') { - app.nodeOutputs[nodeId] = createOutputs([filenames], folder) + app.nodeOutputs[nodeId] = createOutputs([filenames], folder, isAnimated) } else if (!Array.isArray(filenames)) { app.nodeOutputs[nodeId] = filenames } else { - const resultItems = createOutputs(filenames, folder) + const resultItems = createOutputs(filenames, folder, isAnimated) if (!resultItems?.images?.length) return app.nodeOutputs[nodeId] = resultItems } diff --git a/src/utils/imageUtil.ts b/src/utils/imageUtil.ts index 5e6a43eb0..2cfee450b 100644 --- a/src/utils/imageUtil.ts +++ b/src/utils/imageUtil.ts @@ -10,3 +10,20 @@ export const is_all_same_aspect_ratio = (imgs: HTMLImageElement[]): boolean => { return true } + +export const fitDimensionsToNodeWidth = ( + width: number, + height: number, + nodeWidth: number, + minHeight: number = 64 +): { minHeight: number; minWidth: number } => { + const intrinsicAspectRatio = width / height + if (!intrinsicAspectRatio || isNaN(intrinsicAspectRatio)) + return { minHeight: 0, minWidth: 0 } + + // Set min. height s.t. image spans node's x-axis while maintaining aspect ratio + const minWidth = nodeWidth + const calculatedHeight = Math.max(minWidth / intrinsicAspectRatio, minHeight) + + return { minHeight: calculatedHeight, minWidth } +}