From 33e99da325295bcdc6cb2eb0c2bbf2b791e39171 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Mon, 9 Jun 2025 01:48:03 -0700 Subject: [PATCH] Add Vue File/Media Upload Widget (#4115) --- .../graph/widgets/MediaLoaderWidget.vue | 150 ++++++++++++++++ src/composables/node/useNodeImageUpload.ts | 33 +++- src/composables/node/useNodeMediaUpload.ts | 86 +++++++++ .../widgets/useDropdownComboWidget.ts | 2 +- .../widgets/useImageUploadMediaWidget.ts | 163 ++++++++++++++++++ .../widgets/useMediaLoaderWidget.ts | 70 ++++++++ src/scripts/widgets.ts | 10 +- .../composables/useMediaLoaderWidget.test.ts | 108 ++++++++++++ .../composables/useNodeMediaUpload.test.ts | 114 ++++++++++++ 9 files changed, 729 insertions(+), 7 deletions(-) create mode 100644 src/components/graph/widgets/MediaLoaderWidget.vue create mode 100644 src/composables/node/useNodeMediaUpload.ts create mode 100644 src/composables/widgets/useImageUploadMediaWidget.ts create mode 100644 src/composables/widgets/useMediaLoaderWidget.ts create mode 100644 tests-ui/composables/useMediaLoaderWidget.test.ts create mode 100644 tests-ui/composables/useNodeMediaUpload.test.ts diff --git a/src/components/graph/widgets/MediaLoaderWidget.vue b/src/components/graph/widgets/MediaLoaderWidget.vue new file mode 100644 index 000000000..025e549e0 --- /dev/null +++ b/src/components/graph/widgets/MediaLoaderWidget.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/src/composables/node/useNodeImageUpload.ts b/src/composables/node/useNodeImageUpload.ts index b8415cb3d..47ddebe17 100644 --- a/src/composables/node/useNodeImageUpload.ts +++ b/src/composables/node/useNodeImageUpload.ts @@ -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 diff --git a/src/composables/node/useNodeMediaUpload.ts b/src/composables/node/useNodeMediaUpload.ts new file mode 100644 index 000000000..67435b217 --- /dev/null +++ b/src/composables/node/useNodeMediaUpload.ts @@ -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 + } +} diff --git a/src/composables/widgets/useDropdownComboWidget.ts b/src/composables/widgets/useDropdownComboWidget.ts index 2d299f013..dc66f5416 100644 --- a/src/composables/widgets/useDropdownComboWidget.ts +++ b/src/composables/widgets/useDropdownComboWidget.ts @@ -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 diff --git a/src/composables/widgets/useImageUploadMediaWidget.ts b/src/composables/widgets/useImageUploadMediaWidget.ts new file mode 100644 index 000000000..ace774279 --- /dev/null +++ b/src/composables/widgets/useImageUploadMediaWidget.ts @@ -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( + { + 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 +} diff --git a/src/composables/widgets/useMediaLoaderWidget.ts b/src/composables/widgets/useMediaLoaderWidget.ts new file mode 100644 index 000000000..caafd895e --- /dev/null +++ b/src/composables/widgets/useMediaLoaderWidget.ts @@ -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 { + onFilesSelected?: (files: File[]) => void +} + +export const useMediaLoaderWidget = (options: MediaLoaderOptions = {}) => { + const widgetConstructor: ComfyWidgetConstructorV2 = ( + node: LGraphNode, + inputSpec: InputSpec + ) => { + // Initialize widget value + const widgetValue = ref(options.defaultValue ?? []) + + // Create the widget instance + const widget = new ComponentWidgetImpl({ + 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 +} diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index 296a7dd25..41adc7617 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -9,8 +9,9 @@ import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget' import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget' import { useComboWidget } from '@/composables/widgets/useComboWidget' -import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget' +import { useImageUploadMediaWidget } from '@/composables/widgets/useImageUploadMediaWidget' import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget' +import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget' import { useStringWidget } from '@/composables/widgets/useStringWidget' import { useStringWidgetVue } from '@/composables/widgets/useStringWidgetVue' import { t } from '@/i18n' @@ -288,7 +289,8 @@ export const ComfyWidgets: Record = { STRING_DOM: transformWidgetConstructorV2ToV1(useStringWidget()), // Fallback to DOM-based implementation MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()), COMBO: transformWidgetConstructorV2ToV1(useComboWidget()), - IMAGEUPLOAD: useImageUploadWidget(), - BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput()), - COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget()) + COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget()), + IMAGEUPLOAD: useImageUploadMediaWidget(), + MEDIA_LOADER: transformWidgetConstructorV2ToV1(useMediaLoaderWidget()), + BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput()) } diff --git a/tests-ui/composables/useMediaLoaderWidget.test.ts b/tests-ui/composables/useMediaLoaderWidget.test.ts new file mode 100644 index 000000000..b17ee0b70 --- /dev/null +++ b/tests-ui/composables/useMediaLoaderWidget.test.ts @@ -0,0 +1,108 @@ +import type { LGraphNode } from '@comfyorg/litegraph' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' + +// Mock dependencies +vi.mock('@/scripts/domWidget', () => ({ + ComponentWidgetImpl: class MockComponentWidgetImpl { + node: any + name: string + component: any + inputSpec: any + props: any + options: any + + constructor(config: any) { + this.node = config.node + this.name = config.name + this.component = config.component + this.inputSpec = config.inputSpec + this.props = config.props + this.options = config.options + } + }, + addWidget: vi.fn() +})) + +vi.mock('@/components/graph/widgets/MediaLoaderWidget.vue', () => ({ + default: {} +})) + +describe('useMediaLoaderWidget', () => { + let mockNode: LGraphNode + let mockInputSpec: InputSpec + + beforeEach(() => { + mockNode = { + id: 1, + widgets: [] + } as unknown as LGraphNode + + mockInputSpec = { + name: 'test_media_loader', + type: 'MEDIA_LOADER' + } + }) + + it('creates widget constructor with default options', () => { + const constructor = useMediaLoaderWidget() + expect(constructor).toBeInstanceOf(Function) + }) + + it('creates widget with custom options', () => { + const onFilesSelected = vi.fn() + const constructor = useMediaLoaderWidget({ + defaultValue: ['test.jpg'], + minHeight: 120, + accept: 'image/*', + onFilesSelected + }) + + const widget = constructor(mockNode, mockInputSpec) + + expect(widget).toBeDefined() + expect(widget.name).toBe('test_media_loader') + expect((widget.options as any)?.getValue()).toEqual(['test.jpg']) + expect((widget.options as any)?.getMinHeight()).toBe(128) // 120 + 8 padding + expect((widget.options as any)?.onFilesSelected).toBe(onFilesSelected) + }) + + it('handles value setting with validation', () => { + const constructor = useMediaLoaderWidget() + const widget = constructor(mockNode, mockInputSpec) + + // Test valid array + ;(widget.options as any)?.setValue(['file1.jpg', 'file2.png']) + expect((widget.options as any)?.getValue()).toEqual([ + 'file1.jpg', + 'file2.png' + ]) + + // Test invalid value conversion + ;(widget.options as any)?.setValue('invalid' as any) + expect((widget.options as any)?.getValue()).toEqual([]) + }) + + it('sets correct minimum height with padding', () => { + const constructor = useMediaLoaderWidget({ minHeight: 150 }) + const widget = constructor(mockNode, mockInputSpec) + + expect((widget.options as any)?.getMinHeight()).toBe(158) // 150 + 8 padding + }) + + it('uses default minimum height when not specified', () => { + const constructor = useMediaLoaderWidget() + const widget = constructor(mockNode, mockInputSpec) + + expect((widget.options as any)?.getMinHeight()).toBe(108) // 100 + 8 padding + }) + + it('passes accept prop to widget', () => { + const constructor = useMediaLoaderWidget({ accept: 'video/*' }) + const widget = constructor(mockNode, mockInputSpec) + + expect((widget as any).props?.accept).toBe('video/*') + }) +}) diff --git a/tests-ui/composables/useNodeMediaUpload.test.ts b/tests-ui/composables/useNodeMediaUpload.test.ts new file mode 100644 index 000000000..0898dcbaa --- /dev/null +++ b/tests-ui/composables/useNodeMediaUpload.test.ts @@ -0,0 +1,114 @@ +import type { LGraphNode } from '@comfyorg/litegraph' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useNodeMediaUpload } from '@/composables/node/useNodeMediaUpload' + +// Mock dependencies +vi.mock('@/composables/widgets/useMediaLoaderWidget', () => ({ + useMediaLoaderWidget: vi.fn(() => + vi.fn(() => ({ + name: '$$node-media-loader', + options: { + onFilesSelected: null + } + })) + ) +})) + +vi.mock('@/composables/node/useNodeImageUpload', () => ({ + useNodeImageUpload: vi.fn(() => ({ + handleUpload: vi.fn() + })) +})) + +describe('useNodeMediaUpload', () => { + let mockNode: LGraphNode + + beforeEach(() => { + mockNode = { + id: 1, + widgets: [], + setDirtyCanvas: vi.fn() + } as unknown as LGraphNode + }) + + it('creates composable with required methods', () => { + const { showMediaLoader, removeMediaLoader, addMediaLoaderWidget } = + useNodeMediaUpload() + + expect(showMediaLoader).toBeInstanceOf(Function) + expect(removeMediaLoader).toBeInstanceOf(Function) + expect(addMediaLoaderWidget).toBeInstanceOf(Function) + }) + + it('shows media loader widget with options', () => { + const { showMediaLoader } = useNodeMediaUpload() + const options = { + fileFilter: (file: File) => file.type.startsWith('image/'), + onUploadComplete: vi.fn(), + allow_batch: true, + accept: 'image/*' + } + + const widget = showMediaLoader(mockNode, options) + + expect(widget).toBeDefined() + expect(widget.name).toBe('$$node-media-loader') + expect(mockNode.setDirtyCanvas).toHaveBeenCalledWith(true) + }) + + it('removes media loader widget from node', () => { + const { showMediaLoader, removeMediaLoader } = useNodeMediaUpload() + const options = { + fileFilter: () => true, + onUploadComplete: vi.fn() + } + + // Add widget + showMediaLoader(mockNode, options) + mockNode.widgets = [ + { + name: '$$node-media-loader', + onRemove: vi.fn() + } + ] as any + + // Remove widget + removeMediaLoader(mockNode) + + expect(mockNode.widgets).toHaveLength(0) + }) + + it('handles node without widgets gracefully', () => { + const { removeMediaLoader } = useNodeMediaUpload() + const nodeWithoutWidgets = { id: 1 } as LGraphNode + + expect(() => removeMediaLoader(nodeWithoutWidgets)).not.toThrow() + }) + + it('does not remove non-matching widgets', () => { + const { removeMediaLoader } = useNodeMediaUpload() + const otherWidget = { name: 'other-widget' } + mockNode.widgets! = [otherWidget] as any + + removeMediaLoader(mockNode) + + expect(mockNode.widgets).toHaveLength(1) + expect(mockNode.widgets![0]).toBe(otherWidget) + }) + + it('calls widget onRemove when removing', () => { + const { removeMediaLoader } = useNodeMediaUpload() + const onRemove = vi.fn() + mockNode.widgets! = [ + { + name: '$$node-media-loader', + onRemove + } + ] as any + + removeMediaLoader(mockNode) + + expect(onRemove).toHaveBeenCalled() + }) +})