Files
ComfyUI_frontend/src/composables/node/useNodeImage.ts
Arjan Singh 35d53c2c75 feat(WidgetSelectDropdown): support mapped display names (#6602)
## Summary

Add the ability for `WidgetSelectDropdown` to leverage `getOptionLabel`
for custom display labels.

## Review Focus

Will note inline.

## Screenshots


https://github.com/user-attachments/assets/0167cc12-e23d-4b6d-8f7f-74fd97a18397

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6602-feat-WidgetSelectDropdown-support-mapped-display-names-2a26d73d365081709c56c846e3455339)
by [Unito](https://www.unito.io)
2025-11-05 13:12:59 -08:00

203 lines
5.3 KiB
TypeScript

import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'
const VIDEO_WIDGET_NAME = 'video-preview'
const VIDEO_DEFAULT_OPTIONS = {
playsInline: true,
controls: true,
loop: true
} as const
const MEDIA_LOAD_TIMEOUT = 8192
const MAX_RETRIES = 1
const DEFAULT_VIDEO_SIZE = 256
type MediaElement = HTMLImageElement | HTMLVideoElement
interface NodePreviewOptions<T extends MediaElement> {
loadElement: (url: string) => Promise<T | null>
onLoaded?: (elements: T[]) => void
onFailedLoading?: () => void
}
interface ShowPreviewOptions {
/** If true, blocks new loading operations until the current operation is complete. */
block?: boolean
}
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))
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)))
/**
* Displays media element(s) on the node.
*/
function showPreview(options: ShowPreviewOptions = {}) {
if (node.isLoading) return
const outputUrls = nodeOutputStore.getNodeImageUrls(node)
if (!outputUrls?.length) return
if (options?.block) node.isLoading = true
loadElements(outputUrls)
.then((elements) => {
const validElements = elements.filter(
(el): el is NonNullable<Awaited<T>> => el !== null
)
if (validElements.length) {
onLoaded?.(validElements)
node.graph?.setDirtyCanvas(true)
}
})
.catch(() => {
onFailedLoading?.()
})
.finally(() => {
node.isLoading = false
})
}
return {
showPreview
}
}
/**
* Attaches a preview image to a node.
*/
export const useNodeImage = (node: LGraphNode, callback?: () => void) => {
node.previewMediaType = 'image'
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
})
const onLoaded = (elements: HTMLImageElement[]) => {
node.imageIndex = null
node.imgs = elements
callback?.()
}
return useNodePreview(node, {
loadElement,
onLoaded,
onFailedLoading: () => {
node.imgs = undefined
}
})
}
/**
* Attaches a preview video to a node.
*/
export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
node.previewMediaType = 'video'
let minHeight = DEFAULT_VIDEO_SIZE
let minWidth = DEFAULT_VIDEO_SIZE
const { handleWheel, handlePointer } = useCanvasInteractions()
const setMinDimensions = (video: HTMLVideoElement) => {
const { minHeight: calculatedHeight, minWidth: calculatedWidth } =
fitDimensionsToNodeWidth(
video.videoWidth,
video.videoHeight,
node.size?.[0] || DEFAULT_VIDEO_SIZE
)
minWidth = calculatedWidth
minHeight = calculatedHeight
}
const loadElement = (url: string): Promise<HTMLVideoElement | null> =>
new Promise((resolve) => {
const video = document.createElement('video')
Object.assign(video, VIDEO_DEFAULT_OPTIONS)
// Add event listeners for canvas interactions
video.addEventListener('wheel', handleWheel)
video.addEventListener('pointermove', handlePointer)
video.addEventListener('pointerdown', handlePointer)
video.onloadeddata = () => {
setMinDimensions(video)
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) {
const widget = node.addDOMWidget(VIDEO_WIDGET_NAME, 'video', container, {
canvasOnly: true,
hideOnZoom: false
})
widget.serialize = false
widget.computeLayoutSize = () => ({
minHeight,
minWidth
})
}
}
const onLoaded = (videoElements: HTMLVideoElement[]) => {
const videoElement = videoElements[0]
if (!videoElement) return
if (!node.videoContainer) {
node.videoContainer = createContainer()
addVideoDomWidget(node.videoContainer)
}
node.videoContainer.replaceChildren(videoElement)
callback?.()
}
return useNodePreview(node, {
loadElement,
onLoaded,
onFailedLoading: () => {
node.videoContainer = undefined
}
})
}