mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
Support previewing animated image uploads (#3479)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
2
.github/workflows/test-ui.yaml
vendored
2
.github/workflows/test-ui.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repository: 'Comfy-Org/ComfyUI_devtools'
|
repository: 'Comfy-Org/ComfyUI_devtools'
|
||||||
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
|
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
|
||||||
ref: '49c8220be49120dbaff85f32813d854d6dff2d05'
|
ref: '9d2421fd3208a310e4d0f71fca2ea0c985759c33'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
BIN
browser_tests/assets/animated_webp.webp
Normal file
BIN
browser_tests/assets/animated_webp.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
11
browser_tests/assets/widgets/load_animated_webp.json
Normal file
11
browser_tests/assets/widgets/load_animated_webp.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"8": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "animated_web.webp"
|
||||||
|
},
|
||||||
|
"class_type": "DevToolsLoadAnimatedImageTest",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load Animated Image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
browser_tests/assets/widgets/save_animated_webp.json
Normal file
60
browser_tests/assets/widgets/save_animated_webp.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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.describe('Load audio widget', () => {
|
||||||
test('Can load audio', async ({ comfyPage }) => {
|
test('Can load audio', async ({ comfyPage }) => {
|
||||||
await comfyPage.loadWorkflow('widgets/load_audio_widget')
|
await comfyPage.loadWorkflow('widgets/load_audio_widget')
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -3,6 +3,7 @@ import type { IWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
|||||||
|
|
||||||
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
|
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
|
||||||
import { createImageHost } from '@/scripts/ui/imagePreview'
|
import { createImageHost } from '@/scripts/ui/imagePreview'
|
||||||
|
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for handling animated image previews in nodes
|
* Composable for handling animated image previews in nodes
|
||||||
@@ -42,6 +43,16 @@ export function useNodeAnimatedImage() {
|
|||||||
widget.serialize = false
|
widget.serialize = false
|
||||||
widget.serializeValue = () => undefined
|
widget.serializeValue = () => undefined
|
||||||
widget.options.host.updateImages(node.imgs)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
|
||||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
|
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'
|
||||||
|
|
||||||
const VIDEO_WIDGET_NAME = 'video-preview'
|
const VIDEO_WIDGET_NAME = 'video-preview'
|
||||||
const VIDEO_DEFAULT_OPTIONS = {
|
const VIDEO_DEFAULT_OPTIONS = {
|
||||||
@@ -131,12 +132,15 @@ export const useNodeVideo = (node: LGraphNode) => {
|
|||||||
let minWidth = DEFAULT_VIDEO_SIZE
|
let minWidth = DEFAULT_VIDEO_SIZE
|
||||||
|
|
||||||
const setMinDimensions = (video: HTMLVideoElement) => {
|
const setMinDimensions = (video: HTMLVideoElement) => {
|
||||||
const intrinsicAspectRatio = video.videoWidth / video.videoHeight
|
const { minHeight: calculatedHeight, minWidth: calculatedWidth } =
|
||||||
if (!intrinsicAspectRatio || isNaN(intrinsicAspectRatio)) return
|
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 = calculatedWidth
|
||||||
minWidth = node.size?.[0] || DEFAULT_VIDEO_SIZE
|
minHeight = calculatedHeight
|
||||||
minHeight = Math.max(minWidth / intrinsicAspectRatio, 64)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadElement = (url: string): Promise<HTMLVideoElement | null> =>
|
const loadElement = (url: string): Promise<HTMLVideoElement | null> =>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const useImageUploadWidget = () => {
|
|||||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||||
const nodeOutputStore = useNodeOutputStore()
|
const nodeOutputStore = useNodeOutputStore()
|
||||||
|
|
||||||
|
const isAnimated = !!inputOptions.animated_image_upload
|
||||||
const isVideo = !!inputOptions.video_upload
|
const isVideo = !!inputOptions.video_upload
|
||||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
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
|
// Add our own callback to the combo widget to render an image when it changes
|
||||||
fileComboWidget.callback = function () {
|
fileComboWidget.callback = function () {
|
||||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value)
|
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||||
|
isAnimated
|
||||||
|
})
|
||||||
node.graph?.setDirtyCanvas(true)
|
node.graph?.setDirtyCanvas(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +103,9 @@ export const useImageUploadWidget = () => {
|
|||||||
// The value isnt set immediately so we need to wait a moment
|
// 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
|
// No change callbacks seem to be fired on initial setting of the value
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value)
|
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||||
|
isAnimated
|
||||||
|
})
|
||||||
showPreview({ block: false })
|
showPreview({ block: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ const isMediaUploadComboInput = (inputSpec: InputSpec) => {
|
|||||||
|
|
||||||
const isUploadInput =
|
const isUploadInput =
|
||||||
inputOptions['image_upload'] === true ||
|
inputOptions['image_upload'] === true ||
|
||||||
inputOptions['video_upload'] === true
|
inputOptions['video_upload'] === true ||
|
||||||
|
inputOptions['animated_image_upload'] === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
|
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({
|
|||||||
image_folder: z.enum(['input', 'output', 'temp']).optional(),
|
image_folder: z.enum(['input', 'output', 'temp']).optional(),
|
||||||
allow_batch: z.boolean().optional(),
|
allow_batch: z.boolean().optional(),
|
||||||
video_upload: z.boolean().optional(),
|
video_upload: z.boolean().optional(),
|
||||||
|
animated_image_upload: z.boolean().optional(),
|
||||||
options: z.array(zComboOption).optional(),
|
options: z.array(zComboOption).optional(),
|
||||||
remote: zRemoteWidgetConfig.optional(),
|
remote: zRemoteWidgetConfig.optional(),
|
||||||
/** Whether the widget is a multi-select widget. */
|
/** Whether the widget is a multi-select widget. */
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ export function createImageHost(node) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
el,
|
el,
|
||||||
|
getCurrentImage() {
|
||||||
|
// @ts-expect-error fixme ts strict error
|
||||||
|
return currentImgs?.[0]
|
||||||
|
},
|
||||||
// @ts-expect-error fixme ts strict error
|
// @ts-expect-error fixme ts strict error
|
||||||
updateImages(imgs) {
|
updateImages(imgs) {
|
||||||
// @ts-expect-error fixme ts strict error
|
// @ts-expect-error fixme ts strict error
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { isVideoNode } from '@/utils/litegraphUtil'
|
|||||||
|
|
||||||
const createOutputs = (
|
const createOutputs = (
|
||||||
filenames: string[],
|
filenames: string[],
|
||||||
type: string
|
type: string,
|
||||||
|
isAnimated: boolean
|
||||||
): ExecutedWsMessage['output'] => {
|
): ExecutedWsMessage['output'] => {
|
||||||
return {
|
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(
|
function setNodeOutputs(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
filenames: string | string[] | ResultItem,
|
filenames: string | string[] | ResultItem,
|
||||||
{ folder = 'input' }: { folder?: string } = {}
|
{
|
||||||
|
folder = 'input',
|
||||||
|
isAnimated = false
|
||||||
|
}: { folder?: string; isAnimated?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
if (!filenames || !node) return
|
if (!filenames || !node) return
|
||||||
|
|
||||||
const nodeId = getNodeId(node)
|
const nodeId = getNodeId(node)
|
||||||
|
|
||||||
if (typeof filenames === 'string') {
|
if (typeof filenames === 'string') {
|
||||||
app.nodeOutputs[nodeId] = createOutputs([filenames], folder)
|
app.nodeOutputs[nodeId] = createOutputs([filenames], folder, isAnimated)
|
||||||
} else if (!Array.isArray(filenames)) {
|
} else if (!Array.isArray(filenames)) {
|
||||||
app.nodeOutputs[nodeId] = filenames
|
app.nodeOutputs[nodeId] = filenames
|
||||||
} else {
|
} else {
|
||||||
const resultItems = createOutputs(filenames, folder)
|
const resultItems = createOutputs(filenames, folder, isAnimated)
|
||||||
if (!resultItems?.images?.length) return
|
if (!resultItems?.images?.length) return
|
||||||
app.nodeOutputs[nodeId] = resultItems
|
app.nodeOutputs[nodeId] = resultItems
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,20 @@ export const is_all_same_aspect_ratio = (imgs: HTMLImageElement[]): boolean => {
|
|||||||
|
|
||||||
return true
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user