Support previewing animated image uploads (#3479)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-04-17 22:20:09 +08:00
committed by GitHub
parent f1a25989d7
commit 87bf2310b6
16 changed files with 232 additions and 14 deletions

View File

@@ -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
)
}
}
}

View File

@@ -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<HTMLVideoElement | null> =>

View File

@@ -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 })
})

View File

@@ -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')

View File

@@ -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. */

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 }
}