Add node video previews (#2635)

This commit is contained in:
bymyself
2025-02-22 16:37:42 -07:00
committed by GitHub
parent c502b86c31
commit f94831d054
12 changed files with 374 additions and 149 deletions

View File

@@ -532,6 +532,12 @@ dialog::backdrop {
height: var(--comfy-img-preview-height);
}
.comfy-img-preview video {
object-fit: contain;
height: 100%;
width: 100%;
}
.comfy-missing-nodes li button {
font-size: 12px;
margin-left: 5px;

View File

@@ -2,21 +2,163 @@ import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
const VIDEO_WIDGET_NAME = 'video-preview' as const
const VIDEO_DEFAULT_OPTIONS = {
playsInline: true,
controls: true,
loop: true
} as const
const MEDIA_LOAD_TIMEOUT = 8192 as const
const MAX_RETRIES = 1 as const
type MediaElement = HTMLImageElement | HTMLVideoElement
interface NodePreviewOptions<T extends MediaElement> {
loadElement: (url: string) => Promise<T | null>
onLoaded?: (elements: T[]) => void
onFailedLoading?: () => void
}
const createContainer = () => {
const container = document.createElement('div')
container.classList.add('comfy-img-preview')
return container
}
const createTimeout = (ms: number) =>
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))
export const useNodePreview = <T extends MediaElement>(
node: LGraphNode,
options: NodePreviewOptions<T>
) => {
const { loadElement, onLoaded, onFailedLoading } = options
const nodeOutputStore = useNodeOutputStore()
const loadElementWithTimeout = async (
url: string,
retryCount = 0
): Promise<T | null> => {
const result = await Promise.race([
loadElement(url),
createTimeout(MEDIA_LOAD_TIMEOUT)
])
if (result === null && retryCount < MAX_RETRIES) {
return loadElementWithTimeout(url, retryCount + 1)
}
return result
}
const loadElements = async (urls: string[]) =>
Promise.all(urls.map((url) => loadElementWithTimeout(url)))
const render = () => {
node.setSizeForImage?.()
node.graph?.setDirtyCanvas(true)
}
/**
* Displays media element(s) on the node.
*/
function showPreview() {
if (node.isLoading) return
const outputUrls = nodeOutputStore.getNodeImageUrls(node)
if (!outputUrls?.length) return
node.isLoading = true
loadElements(outputUrls)
.then((elements) => {
const validElements = elements.filter(
(el): el is NonNullable<Awaited<T>> => el !== null
)
if (validElements.length) {
onLoaded?.(validElements)
render()
}
})
.catch(() => {
onFailedLoading?.()
})
.finally(() => {
node.isLoading = false
})
}
return {
showPreview
}
}
/**
* Attaches a preview image to a node.
*/
export const useNodeImage = (node: LGraphNode) => {
const nodeOutputStore = useNodeOutputStore()
const loadElement = (url: string): Promise<HTMLImageElement | null> =>
new Promise((resolve) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => resolve(null)
img.src = url
})
/** Displays output image(s) on the node. */
function showImage(output: string | string[]) {
if (!output) return
nodeOutputStore.setNodeOutputs(node, output)
node.setSizeForImage?.()
node.graph?.setDirtyCanvas(true)
const onLoaded = (elements: HTMLImageElement[]) => {
node.imageIndex = null
node.imgs = elements
}
return {
showImage
}
return useNodePreview(node, {
loadElement,
onLoaded,
onFailedLoading: () => {
node.imgs = undefined
}
})
}
/**
* Attaches a preview video to a node.
*/
export const useNodeVideo = (node: LGraphNode) => {
const loadElement = (url: string): Promise<HTMLVideoElement | null> =>
new Promise((resolve) => {
const video = document.createElement('video')
Object.assign(video, VIDEO_DEFAULT_OPTIONS)
video.onloadeddata = () => resolve(video)
video.onerror = () => resolve(null)
video.src = url
})
const addVideoDomWidget = (container: HTMLElement) => {
const hasWidget = node.widgets?.some((w) => w.name === VIDEO_WIDGET_NAME)
if (!hasWidget) {
node.addDOMWidget(VIDEO_WIDGET_NAME, 'video', container, {
hideOnZoom: false
})
}
}
const onLoaded = (videoElements: HTMLVideoElement[]) => {
const videoElement = videoElements[0]
if (!videoElement) return
if (!node.videoContainer) {
node.videoContainer = createContainer()
addVideoDomWidget(node.videoContainer)
}
node.videoContainer.replaceChildren(videoElement)
node.imageOffset = 64
}
return useNodePreview(node, {
loadElement,
onLoaded,
onFailedLoading: () => {
node.videoContainer = undefined
}
})
}

View File

@@ -6,7 +6,6 @@ import { useNodePaste } from '@/composables/useNodePaste'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
const ACCEPTED_IMAGE_TYPES = 'image/jpeg,image/png,image/webp'
const PASTED_IMAGE_EXPIRY_MS = 2000
const uploadFile = async (file: File, isPasted: boolean) => {
@@ -32,6 +31,11 @@ interface ImageUploadOptions {
fileFilter?: (file: File) => boolean
onUploadComplete: (paths: string[]) => void
allow_batch?: boolean
/**
* The file types to accept.
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
*/
accept?: string
}
/**
@@ -41,7 +45,7 @@ export const useNodeImageUpload = (
node: LGraphNode,
options: ImageUploadOptions
) => {
const { fileFilter, onUploadComplete, allow_batch } = options
const { fileFilter, onUploadComplete, allow_batch, accept } = options
const isPastedFile = (file: File): boolean =>
file.name === 'image.png' &&
@@ -81,7 +85,7 @@ export const useNodeImageUpload = (
const { openFileSelection } = useNodeFileInput({
fileFilter,
allow_batch,
accept: ACCEPTED_IMAGE_TYPES,
accept,
onSelect: handleUploadBatch
})

View File

@@ -6,7 +6,7 @@ import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import { isImageNode } from '@/utils/litegraphUtil'
import { isImageNode, isVideoNode } from '@/utils/litegraphUtil'
/**
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
@@ -15,6 +15,23 @@ export const usePaste = () => {
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const pasteItemOnNode = (
items: DataTransferItemList,
node: LGraphNode | null
) => {
if (!node) return
const blob = items[0]?.getAsFile()
if (!blob) return
node.pasteFile?.(blob)
node.pasteFiles?.(
Array.from(items)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
)
}
useEventListener(document, 'paste', async (e: ClipboardEvent) => {
// ctrl+shift+v is used to paste nodes with connections
// this is handled by litegraph
@@ -30,38 +47,38 @@ export const usePaste = () => {
let data = e.clipboardData || window.clipboardData
const items: DataTransferItemList = data.items
const currentNode = canvas.current_node as LGraphNode
const isNodeSelected = currentNode?.is_selected
const isImageNodeSelected = isNodeSelected && isImageNode(currentNode)
const isVideoNodeSelected = isNodeSelected && isVideoNode(currentNode)
let imageNode: LGraphNode | null = isImageNodeSelected ? currentNode : null
const videoNode: LGraphNode | null = isVideoNodeSelected
? currentNode
: null
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
let imageNode: LGraphNode | null = null
// If an image node is selected, paste into it
const currentNode = canvas.current_node as LGraphNode
if (
currentNode &&
currentNode.is_selected &&
isImageNode(currentNode)
) {
imageNode = currentNode
}
// No image node selected: add a new one
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
// @ts-expect-error array to Float32Array
newNode.pos = [...canvas.graph_mouse]
imageNode = graph.add(newNode) ?? null
graph.change()
}
const blob = item.getAsFile()
if (blob) imageNode?.pasteFile?.(blob)
imageNode?.pasteFiles?.(
Array.from(items)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
)
pasteItemOnNode(items, imageNode)
return
} else if (item.type.startsWith('video/')) {
if (!videoNode) {
// No video node selected: add a new one
// TODO: when video node exists
} else {
pasteItemOnNode(items, videoNode)
return
}
}
}

View File

@@ -1,20 +1,25 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { useNodeImage } from '@/composables/useNodeImage'
import { useNodeImage, useNodeVideo } from '@/composables/useNodeImage'
import { useNodeImageUpload } from '@/composables/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type { ComfyApp } from '@/types'
import type { InputSpec, ResultItem } from '@/types/apiTypes'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
type InternalFile = string | ResultItem
type InternalValue = InternalFile | InternalFile[]
type ExposedValue = string | string[]
const isImageFile = (file: File) => file.type.startsWith('image/')
const isVideoFile = (file: File) => file.type.startsWith('video/')
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
@@ -30,9 +35,13 @@ export const useImageUploadWidget = () => {
) => {
const inputOptions = inputData[1] ?? {}
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const nodeOutputStore = useNodeOutputStore()
const { showImage } = useNodeImage(node)
const isVideo = !!inputOptions.video_upload
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const fileFilter = isVideo ? isVideoFile : isImageFile
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const formatPath = (value: InternalFile) =>
@@ -56,7 +65,8 @@ export const useImageUploadWidget = () => {
// Setup file upload handling
const { openFileSelection } = useNodeImageUpload(node, {
allow_batch,
fileFilter: isImageFile,
fileFilter,
accept,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
// @ts-expect-error litegraph combo value type does not support arrays yet
@@ -78,7 +88,7 @@ export const useImageUploadWidget = () => {
// Add our own callback to the combo widget to render an image when it changes
const cb = node.callback
fileComboWidget.callback = function (...args) {
showImage(fileComboWidget.value)
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value)
if (cb) return cb.apply(this, args)
}
@@ -86,7 +96,8 @@ 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(() => {
showImage(fileComboWidget.value)
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value)
showPreview()
})
return { widget: uploadWidget }

View File

@@ -7,7 +7,11 @@ import { applyTextReplacements } from '../../scripts/utils'
app.registerExtension({
name: 'Comfy.SaveImageExtraOutput',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === 'SaveImage' || nodeData.name === 'SaveAnimatedWEBP') {
if (
nodeData.name === 'SaveImage' ||
nodeData.name === 'SaveAnimatedWEBP' ||
nodeData.name === 'SaveAnimatedWEBM'
) {
const onNodeCreated = nodeType.prototype.onNodeCreated
// When the SaveImage node is created we want to override the serialization of the output name widget to run our S&R
nodeType.prototype.onNodeCreated = function () {

View File

@@ -4,10 +4,17 @@ import { app } from '../../scripts/app'
// Adds an upload button to the nodes
const isImageComboInput = (inputSpec: InputSpec) => {
const isMediaUploadComboInput = (inputSpec: InputSpec) => {
const [inputName, inputOptions] = inputSpec
if (!inputOptions || inputOptions['image_upload'] !== true) return false
return isComboInputSpecV1(inputSpec) || inputName === 'COMBO'
if (!inputOptions) return false
const isUploadInput =
inputOptions['image_upload'] === true ||
inputOptions['video_upload'] === true
return (
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
)
}
const createUploadInput = (
@@ -29,10 +36,10 @@ app.registerExtension({
if (!required) return
const found = Object.entries(required).find(([_, input]) =>
isImageComboInput(input)
isMediaUploadComboInput(input)
)
// If image combo input found, attach upload input
// If media combo input found, attach upload input
if (found) {
const [inputName, inputSpec] = found
required.upload = createUploadInput(inputName, inputSpec)

View File

@@ -9,6 +9,7 @@ import {
import { Vector2 } from '@comfyorg/litegraph'
import { IBaseWidget, IWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { useNodeImage, useNodeVideo } from '@/composables/useNodeImage'
import { st } from '@/i18n'
import { ANIM_PREVIEW_WIDGET, ComfyApp, app } from '@/scripts/app'
import { $el } from '@/scripts/ui'
@@ -20,7 +21,7 @@ import { ComfyNodeDef } from '@/types/apiTypes'
import type { NodeId } from '@/types/comfyWorkflow'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
import { isImageNode } from '@/utils/litegraphUtil'
import { getImageTop, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import { useExtensionService } from './extensionService'
@@ -363,26 +364,6 @@ export const useLitegraphService = () => {
* @param {*} node The node to add the draw handler
*/
function addDrawBackgroundHandler(node: typeof LGraphNode) {
function getImageTop(node: LGraphNode) {
let shiftY: number
if (node.imageOffset != null) {
return node.imageOffset
} else if (node.widgets?.length) {
const w = node.widgets[node.widgets.length - 1]
shiftY = w.last_y
if (w.computeSize) {
shiftY += w.computeSize()[1] + 4
} else if (w.computedHeight) {
shiftY += w.computedHeight
} else {
shiftY += LiteGraph.NODE_WIDGET_HEIGHT + 4
}
} else {
return node.computeSize()[1]
}
return shiftY
}
node.prototype.setSizeForImage = function (
this: LGraphNode,
force: boolean
@@ -405,49 +386,24 @@ export const useLitegraphService = () => {
) {
if (this.flags.collapsed) return
let imgURLs: string[] = []
const nodeOutputStore = useNodeOutputStore()
const output = nodeOutputStore.getNodeOutputs(this)
let imagesChanged = nodeOutputStore.isImagesChanged(this)
if (output && imagesChanged) {
this.animatedImages = output.animated?.find(Boolean)
this.images = output.images
imgURLs = nodeOutputStore.getNodeImageUrls(this)
}
const preview = nodeOutputStore.getNodePreviews(this)
if (this.preview !== preview) {
this.preview = preview
imagesChanged = true
if (preview != null) {
imgURLs.push(...preview)
}
}
if (imagesChanged) {
this.imageIndex = null
if (imgURLs.length > 0) {
Promise.all(
imgURLs.flat().map((src) => {
return new Promise<HTMLImageElement | null>((r) => {
const img = new Image()
img.onload = () => r(img)
img.onerror = () => r(null)
img.src = src
})
})
).then((imgs) => {
if (
(!output || this.images === output.images) &&
(!preview || this.preview === preview)
) {
this.imgs = imgs.filter(Boolean)
this.setSizeForImage?.()
app.graph.setDirtyCanvas(true)
}
})
const isNewOutput = output && this.images !== output.images
const isNewPreview = preview && this.preview !== preview
if (isNewPreview) this.preview = preview
if (isNewOutput) this.images = output.images
if (isNewOutput || isNewPreview) {
this.animatedImages = output?.animated?.find(Boolean)
if (this.animatedImages || isVideoNode(this)) {
useNodeVideo(this).showPreview()
} else {
this.imgs = undefined
useNodeImage(this).showPreview()
}
}

View File

@@ -5,7 +5,7 @@ import { api } from '@/scripts/api'
import { ExecutedWsMessage, ResultItem } from '@/types/apiTypes'
import { parseFilePath } from '@/utils/formatUtil'
const toOutputs = (
const createOutputs = (
filenames: string[],
type: string
): ExecutedWsMessage['output'] => {
@@ -14,62 +14,56 @@ const toOutputs = (
}
}
const getPreviewParam = (node: LGraphNode) => {
const getPreviewParam = (node: LGraphNode): string => {
if (node.animatedImages) return ''
return app.getPreviewFormatParam()
}
export const useNodeOutputStore = defineStore('nodeOutput', () => {
function getNodeOutputs(node: LGraphNode): ExecutedWsMessage['output'] {
return app.nodeOutputs[node.id + '']
const getNodeId = (node: LGraphNode): string => node.id.toString()
function getNodeOutputs(
node: LGraphNode
): ExecutedWsMessage['output'] | undefined {
return app.nodeOutputs[getNodeId(node)]
}
function getNodePreviews(node: LGraphNode): string[] {
return app.nodePreviewImages[node.id + '']
function getNodePreviews(node: LGraphNode): string[] | undefined {
return app.nodePreviewImages[getNodeId(node)]
}
function getNodeImageUrls(node: LGraphNode): string[] {
function getNodeImageUrls(node: LGraphNode): string[] | undefined {
const previews = getNodePreviews(node)
if (previews?.length) return previews
const outputs = getNodeOutputs(node)
if (!outputs?.images?.length) return []
if (!outputs?.images?.length) return
const rand = app.getRandParam()
const previewParam = getPreviewParam(node)
return outputs.images.map((image) => {
const imgUrlPart = new URLSearchParams(image)
const rand = app.getRandParam()
const previewParam = getPreviewParam(node)
return api.apiURL(`/view?${imgUrlPart}${previewParam}${rand}`)
})
}
/**
* Checks if the node's images have changed from what's stored
* @returns true if images have changed, false otherwise
*/
function isImagesChanged(node: LGraphNode): boolean {
const currentImages = node.images || []
const { images: newImages } = getNodeOutputs(node) ?? {}
if (!newImages?.length) return false
return currentImages !== newImages
}
function setNodeOutputs(
node: LGraphNode,
filenames: string | string[] | ResultItem,
options: { folder?: string } = {}
{ folder = 'input' }: { folder?: string } = {}
) {
if (!filenames) return
if (!filenames || !node) return
const { folder = 'input' } = options
const nodeId = node.id + ''
const nodeId = getNodeId(node)
if (typeof filenames === 'string') {
app.nodeOutputs[nodeId] = toOutputs([filenames], folder)
app.nodeOutputs[nodeId] = createOutputs([filenames], folder)
} else if (!Array.isArray(filenames)) {
app.nodeOutputs[nodeId] = filenames
} else {
const resultItems = toOutputs(filenames, folder)
const resultItems = createOutputs(filenames, folder)
if (!resultItems?.images?.length) return
app.nodeOutputs[nodeId] = resultItems
}
}
@@ -78,7 +72,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
getNodeOutputs,
getNodeImageUrls,
getNodePreviews,
setNodeOutputs,
isImagesChanged
setNodeOutputs
}
})

View File

@@ -111,6 +111,10 @@ declare module '@comfyorg/litegraph' {
animatedImages?: boolean
imgs?: HTMLImageElement[]
images?: ExecutedWsMessage['output']
/** Container for the node's video preview */
videoContainer?: HTMLElement
/** Whether the node's preview media is loading */
isLoading?: boolean
preview: string[]
/** Index of the currently selected image on a multi-image node such as Preview Image */

View File

@@ -215,9 +215,11 @@ export function isValidUrl(url: string): boolean {
return false
}
}
const hasAnnotation = (filepath: string): boolean =>
/\[(input|output|temp)\]/i.test(filepath)
const createAnnotation = (rootFolder = 'input'): string =>
rootFolder !== 'input' ? ` [${rootFolder}]` : ''
const createAnnotation = (filepath: string, rootFolder = 'input'): string =>
!hasAnnotation(filepath) && rootFolder !== 'input' ? ` [${rootFolder}]` : ''
const createPath = (filename: string, subfolder = ''): string =>
subfolder ? `${subfolder}/${filename}` : filename
@@ -229,8 +231,8 @@ export function createAnnotatedPath(
): string {
const { rootFolder = 'input', subfolder } = options
if (typeof item === 'string')
return `${createPath(item, subfolder)}${createAnnotation(rootFolder)}`
return `${createPath(item.filename ?? '', item.subfolder)}${createAnnotation(item.type)}`
return `${createPath(item, subfolder)}${createAnnotation(item, rootFolder)}`
return `${createPath(item.filename ?? '', item.subfolder)}${item.type ? createAnnotation(item.type, rootFolder) : ''}`
}
/**

View File

@@ -1,15 +1,74 @@
import type { ColorOption, IWidget } from '@comfyorg/litegraph'
import { LGraphGroup, LGraphNode, isColorable } from '@comfyorg/litegraph'
import type { ColorOption } from '@comfyorg/litegraph'
import {
LGraphGroup,
LGraphNode,
LiteGraph,
isColorable
} from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import _ from 'lodash'
export function isImageNode(node: LGraphNode) {
return (
node.imgs ||
(node &&
node.widgets &&
node.widgets.findIndex((obj: IWidget) => obj.name === 'image') >= 0)
)
import { ComfyInputsSpec, ComfyNodeDef, InputSpec } from '@/types/apiTypes'
const IMAGE_NODE_PROPERTY = 'image_upload'
const VIDEO_NODE_PROPERTY = 'video_upload'
const getNodeData = (node: LGraphNode): ComfyNodeDef | undefined =>
node.constructor?.nodeData as ComfyNodeDef | undefined
const getInputSpecsFromData = (
inputData: ComfyInputsSpec | undefined
): InputSpec[] => {
if (!inputData) return []
const { required, optional } = inputData
const inputSpecs: InputSpec[] = []
if (required) {
for (const value of Object.values(required)) {
inputSpecs.push(value)
}
}
if (optional) {
for (const value of Object.values(optional)) {
inputSpecs.push(value)
}
}
return inputSpecs
}
const hasImageElements = (imgs: unknown[]): boolean =>
Array.isArray(imgs) &&
imgs.some((img): img is HTMLImageElement => img instanceof HTMLImageElement)
const hasInputProperty = (
node: LGraphNode | undefined,
property: string
): boolean => {
if (!node) return false
const nodeData = getNodeData(node)
if (!nodeData?.input) return false
const inputs = getInputSpecsFromData(nodeData.input)
return inputs.some((input) => input?.[1]?.[property])
}
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] }
type VideoNode = LGraphNode & { videoContainer: HTMLElement }
export function isImageNode(node: LGraphNode | undefined): node is ImageNode {
if (!node) return false
if (node.imgs?.length && hasImageElements(node.imgs)) return true
if (!node.widgets) return false
return hasInputProperty(node, IMAGE_NODE_PROPERTY)
}
export function isVideoNode(node: LGraphNode | undefined): node is VideoNode {
if (!node) return false
if (node.videoContainer) return true
if (!node.widgets) return false
return hasInputProperty(node, VIDEO_NODE_PROPERTY)
}
export function addToComboValues(widget: IComboWidget, value: string) {
@@ -56,3 +115,23 @@ export function executeWidgetsCallback(
}
}
}
export function getImageTop(node: LGraphNode) {
let shiftY: number
if (node.imageOffset != null) {
return node.imageOffset
} else if (node.widgets?.length) {
const w = node.widgets[node.widgets.length - 1]
shiftY = w.last_y ?? 0
if (w.computeSize) {
shiftY += w.computeSize()[1] + 4
} else if (w.computedHeight) {
shiftY += w.computedHeight
} else {
shiftY += LiteGraph.NODE_WIDGET_HEIGHT + 4
}
} else {
return node.computeSize()[1]
}
return shiftY
}