mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-09 09:30:06 +00:00
Add Vue File/Media Upload Widget (#4115)
This commit is contained in:
@@ -2,6 +2,7 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodeMediaUpload } from '@/composables/node/useNodeMediaUpload'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -36,16 +37,28 @@ interface ImageUploadOptions {
|
||||
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
|
||||
*/
|
||||
accept?: string
|
||||
/**
|
||||
* Whether to use the new Vue MediaLoader widget instead of traditional drag/drop/paste
|
||||
* @default true
|
||||
*/
|
||||
useMediaLoaderWidget?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds image upload to a node via drag & drop, paste, and file input.
|
||||
* Optionally can use the new Vue MediaLoader widget.
|
||||
*/
|
||||
export const useNodeImageUpload = (
|
||||
node: LGraphNode,
|
||||
options: ImageUploadOptions
|
||||
) => {
|
||||
const { fileFilter, onUploadComplete, allow_batch, accept } = options
|
||||
const {
|
||||
fileFilter,
|
||||
onUploadComplete,
|
||||
allow_batch,
|
||||
accept,
|
||||
useMediaLoaderWidget = true
|
||||
} = options
|
||||
|
||||
const isPastedFile = (file: File): boolean =>
|
||||
file.name === 'image.png' &&
|
||||
@@ -68,7 +81,23 @@ export const useNodeImageUpload = (
|
||||
return validPaths
|
||||
}
|
||||
|
||||
// Handle drag & drop
|
||||
// If using the new MediaLoader widget, set it up and return early
|
||||
if (useMediaLoaderWidget) {
|
||||
const { showMediaLoader } = useNodeMediaUpload()
|
||||
const widget = showMediaLoader(node, {
|
||||
fileFilter,
|
||||
onUploadComplete,
|
||||
allow_batch,
|
||||
accept
|
||||
})
|
||||
return {
|
||||
openFileSelection: () => {},
|
||||
handleUpload,
|
||||
mediaLoaderWidget: widget
|
||||
}
|
||||
}
|
||||
|
||||
// Traditional approach: Handle drag & drop
|
||||
useNodeDragAndDrop(node, {
|
||||
fileFilter,
|
||||
onDrop: handleUploadBatch
|
||||
|
||||
86
src/composables/node/useNodeMediaUpload.ts
Normal file
86
src/composables/node/useNodeMediaUpload.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const MEDIA_LOADER_WIDGET_NAME = '$$node-media-loader'
|
||||
|
||||
interface MediaUploadOptions {
|
||||
fileFilter?: (file: File) => boolean
|
||||
onUploadComplete: (paths: string[]) => void
|
||||
allow_batch?: boolean
|
||||
accept?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling media upload with Vue MediaLoader widget
|
||||
*/
|
||||
export function useNodeMediaUpload() {
|
||||
const mediaLoaderWidget = useMediaLoaderWidget()
|
||||
|
||||
const findMediaLoaderWidget = (node: LGraphNode) =>
|
||||
node.widgets?.find((w) => w.name === MEDIA_LOADER_WIDGET_NAME)
|
||||
|
||||
const addMediaLoaderWidget = (
|
||||
node: LGraphNode,
|
||||
options: MediaUploadOptions
|
||||
) => {
|
||||
// Set up the file upload handling using existing logic
|
||||
const { handleUpload } = useNodeImageUpload(node, options)
|
||||
|
||||
// Create the MediaLoader widget
|
||||
const widget = mediaLoaderWidget(node, {
|
||||
name: MEDIA_LOADER_WIDGET_NAME,
|
||||
type: 'MEDIA_LOADER'
|
||||
} as InputSpec)
|
||||
|
||||
// Connect the widget to the upload handler
|
||||
if (widget.options) {
|
||||
;(widget.options as any).onFilesSelected = async (files: File[]) => {
|
||||
const paths = await Promise.all(files.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
if (validPaths.length) {
|
||||
options.onUploadComplete(validPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows media loader widget for a node
|
||||
* @param node The graph node to show the widget for
|
||||
* @param options Upload configuration options
|
||||
*/
|
||||
function showMediaLoader(node: LGraphNode, options: MediaUploadOptions) {
|
||||
const widget =
|
||||
findMediaLoaderWidget(node) ?? addMediaLoaderWidget(node, options)
|
||||
node.setDirtyCanvas?.(true)
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes media loader widget from a node
|
||||
* @param node The graph node to remove the widget from
|
||||
*/
|
||||
function removeMediaLoader(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
|
||||
const widgetIdx = node.widgets.findIndex(
|
||||
(w) => w.name === MEDIA_LOADER_WIDGET_NAME
|
||||
)
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
node.widgets[widgetIdx].onRemove?.()
|
||||
node.widgets.splice(widgetIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showMediaLoader,
|
||||
removeMediaLoader,
|
||||
addMediaLoaderWidget
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export const useDropdownComboWidget = (
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget (dropdown needs some height)
|
||||
getMinHeight: () => 42 + PADDING,
|
||||
getMinHeight: () => 48 + PADDING,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
|
||||
163
src/composables/widgets/useImageUploadMediaWidget.ts
Normal file
163
src/composables/widgets/useImageUploadMediaWidget.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
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 & {
|
||||
value: ExposedValue
|
||||
}
|
||||
|
||||
export const useImageUploadMediaWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
const inputOptions = inputData[1] ?? {}
|
||||
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)
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||
const initialFile = `${fileComboWidget.value}`
|
||||
const formatPath = (value: InternalFile) =>
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
const transform = (internalValue: InternalValue): ExposedValue => {
|
||||
if (!internalValue) return initialFile
|
||||
if (Array.isArray(internalValue))
|
||||
return allow_batch
|
||||
? internalValue.map(formatPath)
|
||||
: formatPath(internalValue[0])
|
||||
return formatPath(internalValue)
|
||||
}
|
||||
|
||||
Object.defineProperty(
|
||||
fileComboWidget,
|
||||
'value',
|
||||
useValueTransform(transform, initialFile)
|
||||
)
|
||||
|
||||
// Convert the V1 input spec to V2 format for the MediaLoader widget
|
||||
const inputSpecV2 = transformInputSpecV1ToV2(inputData, { name: inputName })
|
||||
|
||||
// Handle widget dimensions based on input options
|
||||
const getMinHeight = () => {
|
||||
let baseHeight = 200
|
||||
|
||||
// Handle multiline attribute for expanded height
|
||||
if (inputOptions.multiline) {
|
||||
baseHeight = Math.max(
|
||||
baseHeight,
|
||||
inputOptions.multiline === true
|
||||
? 120
|
||||
: Number(inputOptions.multiline) || 120
|
||||
)
|
||||
}
|
||||
|
||||
// Handle other height-related attributes
|
||||
if (inputOptions.min_height) {
|
||||
baseHeight = Math.max(baseHeight, Number(inputOptions.min_height))
|
||||
}
|
||||
|
||||
return baseHeight + 8 // Add padding
|
||||
}
|
||||
|
||||
// Create the MediaLoader widget directly
|
||||
const uploadWidget = new ComponentWidgetImpl<string[], { accept?: string }>(
|
||||
{
|
||||
node,
|
||||
name: inputName,
|
||||
component: MediaLoaderWidget,
|
||||
inputSpec: inputSpecV2,
|
||||
props: {
|
||||
accept
|
||||
},
|
||||
options: {
|
||||
getValue: () => [],
|
||||
setValue: () => {},
|
||||
getMinHeight,
|
||||
serialize: false,
|
||||
onFilesSelected: async (files: File[]) => {
|
||||
// Use the existing upload infrastructure
|
||||
const { handleUpload } = useNodeImageUpload(node, {
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
allow_batch,
|
||||
fileFilter,
|
||||
accept,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) =>
|
||||
addToComboValues(fileComboWidget, path)
|
||||
)
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = output
|
||||
fileComboWidget.callback?.(output)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle each file
|
||||
for (const file of files) {
|
||||
if (fileFilter(file)) {
|
||||
await handleUpload(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
)
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, uploadWidget as any)
|
||||
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
fileComboWidget.callback = function () {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
// On load if we have a value then render the image
|
||||
// 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, {
|
||||
isAnimated
|
||||
})
|
||||
showPreview({ block: false })
|
||||
})
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
70
src/composables/widgets/useMediaLoaderWidget.ts
Normal file
70
src/composables/widgets/useMediaLoaderWidget.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
ComponentWidgetImpl,
|
||||
type DOMWidgetOptions,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
|
||||
interface MediaLoaderOptions {
|
||||
defaultValue?: string[]
|
||||
minHeight?: number
|
||||
accept?: string
|
||||
onFilesSelected?: (files: File[]) => void
|
||||
}
|
||||
|
||||
interface MediaLoaderWidgetOptions extends DOMWidgetOptions<string[]> {
|
||||
onFilesSelected?: (files: File[]) => void
|
||||
}
|
||||
|
||||
export const useMediaLoaderWidget = (options: MediaLoaderOptions = {}) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
// Initialize widget value
|
||||
const widgetValue = ref<string[]>(options.defaultValue ?? [])
|
||||
|
||||
// Create the widget instance
|
||||
const widget = new ComponentWidgetImpl<string[], { accept?: string }>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: MediaLoaderWidget,
|
||||
inputSpec,
|
||||
props: {
|
||||
accept: options.accept
|
||||
},
|
||||
options: {
|
||||
// Required: getter for widget value
|
||||
getValue: () => widgetValue.value,
|
||||
|
||||
// Required: setter for widget value
|
||||
setValue: (value: string[]) => {
|
||||
widgetValue.value = Array.isArray(value) ? value : []
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => (options.minHeight ?? 500) + PADDING,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true,
|
||||
|
||||
// Custom option for file selection callback
|
||||
onFilesSelected: options.onFilesSelected
|
||||
} as MediaLoaderWidgetOptions
|
||||
})
|
||||
|
||||
// Register the widget with the node
|
||||
addWidget(node, widget as any)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
Reference in New Issue
Block a user