mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-31 13:29:55 +00:00
Support batch image upload (#2597)
This commit is contained in:
45
src/composables/useNodeFileInput.ts
Normal file
45
src/composables/useNodeFileInput.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
interface FileInputOptions {
|
||||
accept?: string
|
||||
allow_batch?: boolean
|
||||
fileFilter?: (file: File) => boolean
|
||||
onSelect: (files: File[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file input for a node.
|
||||
*/
|
||||
export function useNodeFileInput(options: FileInputOptions) {
|
||||
const {
|
||||
accept,
|
||||
allow_batch = false,
|
||||
fileFilter = () => true,
|
||||
onSelect
|
||||
} = options
|
||||
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.accept = accept ?? '*'
|
||||
fileInput.multiple = allow_batch
|
||||
fileInput.style.visibility = 'hidden'
|
||||
|
||||
fileInput.onchange = () => {
|
||||
if (fileInput.files?.length) {
|
||||
const files = Array.from(fileInput.files).filter(fileFilter)
|
||||
if (files.length) onSelect(files)
|
||||
}
|
||||
}
|
||||
|
||||
document.body.append(fileInput)
|
||||
|
||||
/**
|
||||
* Shows the system file picker dialog for selecting files.
|
||||
*/
|
||||
function openFileSelection() {
|
||||
fileInput.click()
|
||||
}
|
||||
|
||||
return {
|
||||
fileInput,
|
||||
openFileSelection
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,16 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
interface NodeImageOptions {
|
||||
allowBatch?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a preview image to a node.
|
||||
*/
|
||||
export const useNodeImage = (node: LGraphNode, options: NodeImageOptions) => {
|
||||
const { allowBatch = false } = options
|
||||
export const useNodeImage = (node: LGraphNode) => {
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
/** Displays output image(s) on the node. */
|
||||
function showImage(output: string | string[]) {
|
||||
if (!output) return
|
||||
if (allowBatch || typeof output === 'string') {
|
||||
nodeOutputStore.setNodeOutputs(node, output)
|
||||
} else {
|
||||
nodeOutputStore.setNodeOutputs(node, output[0])
|
||||
}
|
||||
nodeOutputStore.setNodeOutputs(node, output)
|
||||
node.setSizeForImage?.()
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { useNodeDragAndDrop } from '@/composables/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/useNodePaste'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
import { useNodeDragAndDrop } from './useNodeDragAndDrop'
|
||||
import { useNodePaste } from './useNodePaste'
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = 'image/jpeg,image/png,image/webp'
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const createFileInput = () => {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.accept = ACCEPTED_IMAGE_TYPES
|
||||
return fileInput
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
@@ -38,13 +31,17 @@ const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
interface ImageUploadOptions {
|
||||
fileFilter?: (file: File) => boolean
|
||||
onUploadComplete: (paths: string[]) => void
|
||||
allow_batch?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds image upload to a node via drag & drop, paste, and file input.
|
||||
*/
|
||||
export const useNodeImageUpload = (
|
||||
node: LGraphNode,
|
||||
options: ImageUploadOptions
|
||||
) => {
|
||||
const { fileFilter = () => true, onUploadComplete } = options
|
||||
const { fileFilter, onUploadComplete, allow_batch } = options
|
||||
|
||||
const isPastedFile = (file: File): boolean =>
|
||||
file.name === 'image.png' &&
|
||||
@@ -60,48 +57,33 @@ export const useNodeImageUpload = (
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadBatch = async (files: File[]) => {
|
||||
const paths = await Promise.all(files.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
if (validPaths.length) onUploadComplete(validPaths)
|
||||
return validPaths
|
||||
}
|
||||
|
||||
// Handle drag & drop
|
||||
useNodeDragAndDrop(node, {
|
||||
fileFilter,
|
||||
onDrop: async (files) => {
|
||||
const paths = await Promise.all(files.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
if (validPaths.length) {
|
||||
onUploadComplete(validPaths)
|
||||
}
|
||||
return validPaths
|
||||
}
|
||||
onDrop: handleUploadBatch
|
||||
})
|
||||
|
||||
// Handle paste
|
||||
useNodePaste(node, {
|
||||
fileFilter,
|
||||
onPaste: async (file) => {
|
||||
const path = await handleUpload(file)
|
||||
if (path) {
|
||||
onUploadComplete([path])
|
||||
}
|
||||
return path
|
||||
}
|
||||
allow_batch,
|
||||
onPaste: handleUploadBatch
|
||||
})
|
||||
|
||||
// Handle file input
|
||||
const fileInput = createFileInput()
|
||||
fileInput.onchange = async () => {
|
||||
if (fileInput.files?.length) {
|
||||
const paths = await Promise.all(
|
||||
Array.from(fileInput.files).filter(fileFilter).map(handleUpload)
|
||||
)
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
if (validPaths.length) {
|
||||
onUploadComplete(validPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
document.body.append(fileInput)
|
||||
const { openFileSelection } = useNodeFileInput({
|
||||
fileFilter,
|
||||
allow_batch,
|
||||
accept: ACCEPTED_IMAGE_TYPES,
|
||||
onSelect: handleUploadBatch
|
||||
})
|
||||
|
||||
return {
|
||||
fileInput,
|
||||
handleUpload
|
||||
}
|
||||
return { openFileSelection, handleUpload }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
type PasteHandler<T> = (file: File) => Promise<T>
|
||||
type PasteHandler<T> = (files: File[]) => Promise<T>
|
||||
|
||||
interface NodePasteOptions<T> {
|
||||
onPaste: PasteHandler<T>
|
||||
fileFilter?: (file: File) => boolean
|
||||
allow_batch?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,12 +15,15 @@ export const useNodePaste = <T>(
|
||||
node: LGraphNode,
|
||||
options: NodePasteOptions<T>
|
||||
) => {
|
||||
const { onPaste, fileFilter = () => true } = options
|
||||
const { onPaste, fileFilter = () => true, allow_batch = false } = options
|
||||
|
||||
node.pasteFile = function (file: File) {
|
||||
if (!fileFilter(file)) return false
|
||||
node.pasteFiles = function (files: File[]) {
|
||||
const filteredFiles = Array.from(files).filter(fileFilter)
|
||||
if (!filteredFiles.length) return false
|
||||
|
||||
onPaste(file).then((result) => {
|
||||
const paste = allow_batch ? filteredFiles : filteredFiles.slice(0, 1)
|
||||
|
||||
onPaste(paste).then((result) => {
|
||||
if (!result) return
|
||||
})
|
||||
return true
|
||||
|
||||
@@ -28,7 +28,7 @@ export const usePaste = () => {
|
||||
// Did you mean 'Clipboard'?ts(2551)
|
||||
// TODO: Not sure what the code wants to do.
|
||||
let data = e.clipboardData || window.clipboardData
|
||||
const items = data.items
|
||||
const items: DataTransferItemList = data.items
|
||||
|
||||
// Look for image paste data
|
||||
for (const item of items) {
|
||||
@@ -54,7 +54,13 @@ export const usePaste = () => {
|
||||
graph.change()
|
||||
}
|
||||
const blob = item.getAsFile()
|
||||
imageNode?.pasteFile?.(blob)
|
||||
if (blob) imageNode?.pasteFile?.(blob)
|
||||
|
||||
imageNode?.pasteFiles?.(
|
||||
Array.from(items)
|
||||
.map((i) => i.getAsFile())
|
||||
.filter((f) => f !== null)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
31
src/composables/useValueTransform.ts
Normal file
31
src/composables/useValueTransform.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Creates a getter/setter pair that transforms values on access if they have changed.
|
||||
* Does not observe deep changes.
|
||||
*
|
||||
* @example
|
||||
* const { get, set } = useValueTransform<ResultItem[], string[]>(
|
||||
* items => items.map(formatPath)
|
||||
* )
|
||||
*
|
||||
* Object.defineProperty(obj, 'value', { get, set })
|
||||
*/
|
||||
export function useValueTransform<Internal, External>(
|
||||
transform: (value: Internal) => External,
|
||||
initialValue: Internal
|
||||
) {
|
||||
let internalValue: Internal = initialValue
|
||||
let cachedValue: External = transform(initialValue)
|
||||
let isChanged = false
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (!isChanged) return cachedValue
|
||||
cachedValue = transform(internalValue)
|
||||
return cachedValue
|
||||
},
|
||||
set: (value: Internal) => {
|
||||
isChanged = true
|
||||
internalValue = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,23 @@ import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { useNodeImage } from '@/composables/useNodeImage'
|
||||
import { useNodeImageUpload } from '@/composables/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import type { ComfyApp } from '@/types'
|
||||
import type { InputSpec, ResultItem } from '@/types/apiTypes'
|
||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
type InternalFile = string | ResultItem
|
||||
type InternalValue = InternalFile | InternalFile[]
|
||||
type ExposedValue = string | string[]
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
|
||||
const findFileComboWidget = (node: LGraphNode, inputData: InputSpec) =>
|
||||
node.widgets?.find(
|
||||
(w) => w.name === (inputData[1]?.widget ?? 'image') && w.type === 'combo'
|
||||
) as IComboWidget & { value: string }
|
||||
|
||||
const addToComboValues = (widget: IComboWidget, path: string) => {
|
||||
if (!widget.options) widget.options = { values: [] }
|
||||
if (!widget.options.values) widget.options.values = []
|
||||
if (!widget.options.values.includes(path)) {
|
||||
widget.options.values.push(path)
|
||||
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
|
||||
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
|
||||
value: ExposedValue
|
||||
}
|
||||
}
|
||||
|
||||
export const useImageUploadWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
@@ -30,43 +28,46 @@ export const useImageUploadWidget = () => {
|
||||
inputData: InputSpec,
|
||||
app: ComfyApp
|
||||
) => {
|
||||
// TODO: specify upload widget via input spec rather than input name
|
||||
const fileComboWidget = findFileComboWidget(node, inputData)
|
||||
const { allow_batch, image_folder = 'input' } = inputData[1] ?? {}
|
||||
const inputOptions = inputData[1] ?? {}
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
|
||||
const { showImage } = useNodeImage(node)
|
||||
|
||||
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||
const initialFile = `${fileComboWidget.value}`
|
||||
const { showImage } = useNodeImage(node, { allowBatch: allow_batch })
|
||||
const formatPath = (value: InternalFile) =>
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
let internalValue: string | ResultItem = initialFile
|
||||
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)
|
||||
}
|
||||
|
||||
// Setup getter/setter that transforms from `ResultItem` to string and formats paths
|
||||
Object.defineProperty(fileComboWidget, 'value', {
|
||||
set: function (value: string | ResultItem) {
|
||||
internalValue = value
|
||||
},
|
||||
get: function () {
|
||||
if (!internalValue) return initialFile
|
||||
if (typeof internalValue === 'string')
|
||||
return createAnnotatedPath(internalValue, {
|
||||
rootFolder: image_folder
|
||||
})
|
||||
if (!internalValue.filename) return initialFile
|
||||
return createAnnotatedPath(internalValue)
|
||||
}
|
||||
})
|
||||
Object.defineProperty(
|
||||
fileComboWidget,
|
||||
'value',
|
||||
useValueTransform(transform, initialFile)
|
||||
)
|
||||
|
||||
// Setup file upload handling
|
||||
const { fileInput } = useNodeImageUpload(node, {
|
||||
const { openFileSelection } = useNodeImageUpload(node, {
|
||||
allow_batch,
|
||||
fileFilter: isImageFile,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) => addToComboValues(fileComboWidget, path))
|
||||
fileComboWidget.value = output[0]
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = output
|
||||
fileComboWidget.callback?.(output)
|
||||
}
|
||||
})
|
||||
|
||||
// Create the button widget for selecting the files
|
||||
const uploadWidget = node.addWidget('button', inputName, 'image', () =>
|
||||
fileInput.click()
|
||||
openFileSelection()
|
||||
)
|
||||
uploadWidget.label = 'choose file to upload'
|
||||
// @ts-expect-error serialize is not typed
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import { ComfyNodeDef, InputSpec } from '@/types/apiTypes'
|
||||
import { ComfyNodeDef, InputSpec, isComboInputSpecV1 } from '@/types/apiTypes'
|
||||
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
// Adds an upload button to the nodes
|
||||
|
||||
const isImageComboInput = (inputSpec: InputSpec) => {
|
||||
const [inputName, inputOptions] = inputSpec
|
||||
if (!inputOptions || inputOptions['image_upload'] !== true) return false
|
||||
return isComboInputSpecV1(inputSpec) || inputName === 'COMBO'
|
||||
}
|
||||
|
||||
const createUploadInput = (
|
||||
imageInputName: string,
|
||||
imageInputOptions: InputSpec
|
||||
): InputSpec => [
|
||||
'IMAGEUPLOAD',
|
||||
{
|
||||
...imageInputOptions[1],
|
||||
imageInputName
|
||||
}
|
||||
]
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.UploadImage',
|
||||
beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef) {
|
||||
// Check if there is a required input named 'image' in the nodeData
|
||||
const imageInputSpec: InputSpec | undefined =
|
||||
nodeData?.input?.required?.image
|
||||
const { input } = nodeData ?? {}
|
||||
const { required } = input ?? {}
|
||||
if (!required) return
|
||||
|
||||
// Get the config from the image input spec if it exists
|
||||
const config = imageInputSpec?.[1] ?? {}
|
||||
const { image_upload = false, image_folder = 'input' } = config
|
||||
const found = Object.entries(required).find(([_, input]) =>
|
||||
isImageComboInput(input)
|
||||
)
|
||||
|
||||
if (image_upload && nodeData?.input?.required) {
|
||||
nodeData.input.required.upload = ['IMAGEUPLOAD', { image_folder }]
|
||||
// If image combo input found, attach upload input
|
||||
if (found) {
|
||||
const [inputName, inputSpec] = found
|
||||
required.upload = createUploadInput(inputName, inputSpec)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -349,6 +349,7 @@ const zComboInputProps = zBaseInputSpecValue.extend({
|
||||
control_after_generate: z.boolean().optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
image_folder: z.enum(['input', 'output', 'temp']).optional(),
|
||||
allow_batch: z.boolean().optional(),
|
||||
remote: zRemoteWidgetConfig.optional()
|
||||
})
|
||||
|
||||
|
||||
2
src/types/litegraph-augmentation.d.ts
vendored
2
src/types/litegraph-augmentation.d.ts
vendored
@@ -122,6 +122,8 @@ declare module '@comfyorg/litegraph' {
|
||||
imageOffset?: number
|
||||
/** Callback for pasting an image file into the node */
|
||||
pasteFile?(file: File): void
|
||||
/** Callback for pasting multiple files into the node */
|
||||
pasteFiles?(files: File[]): void
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
export function isImageNode(node: LGraphNode) {
|
||||
return (
|
||||
@@ -8,3 +9,11 @@ export function isImageNode(node: LGraphNode) {
|
||||
node.widgets.findIndex((obj: IWidget) => obj.name === 'image') >= 0)
|
||||
)
|
||||
}
|
||||
|
||||
export function addToComboValues(widget: IComboWidget, value: string) {
|
||||
if (!widget.options) widget.options = { values: [] }
|
||||
if (!widget.options.values) widget.options.values = []
|
||||
if (!widget.options.values.includes(value)) {
|
||||
widget.options.values.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user