mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 14:54:37 +00:00
This pull request introduces several improvements to Vue reactivity and user experience in the graph node and widget system. The main focus is on ensuring that changes to node and widget data reliably trigger updates in Vue components, improving drag-and-drop support for nodes, and enhancing widget value handling for better compatibility and reactivity. **Vue Reactivity Improvements:** * In `useGraphNodeManager.ts`, node data updates now create a completely new object and add a timestamp (`_updateTs`) to force Vue's reactivity system to detect changes. Additionally, node data is re-set on the next tick to guarantee component updates. [[1]](diffhunk://#diff-f980db6f42cef913c3fe92669783b255d617e40b9ccef9a1ab9cc8e326ff1790L272-R280) [[2]](diffhunk://#diff-f980db6f42cef913c3fe92669783b255d617e40b9ccef9a1ab9cc8e326ff1790R326-R335) * Widget value composables (`useWidgetValue` and related helpers) now accept either a direct value or a getter function for `modelValue`, and always normalize it to a getter. Watches are updated to use this getter for more reliable reactivity. [[1]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L13-R14) [[2]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911R49-R57) [[3]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L82-R91) [[4]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L100-R104) [[5]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L117-R121) [[6]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L140-R144) [[7]](diffhunk://#diff-0c43cefa9fb524ae86541c7ca851e97a22b3fd01f95795c83273c977be77468fL47-R47) * In `useImageUploadWidget.ts`, widget value updates now use a new array/object to ensure Vue detects the change, especially for batch uploads. **Drag-and-Drop Support for Nodes:** * The `LGraphNode.vue` component adds drag-and-drop event handlers (`dragover`, `dragleave`, `drop`) and visual feedback (`isDraggingOver` state and highlight ring) for improved user experience when dragging files onto nodes. Node callbacks (`onDragOver`, `onDragDrop`) are used for custom validation and handling. [[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L26-R27) [[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R47-R49) [[3]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R482-R521) **Widget and Audio Upload Handling:** * In `uploadAudio.ts`, after uploading an audio file, the widget's callback is manually triggered to ensure Vue nodes update. There is also a commented-out call to mark the canvas as dirty for potential future refresh logic. [[1]](diffhunk://#diff-796b36f2cafb906a5e95b5750ca5ddc1bf57a304d4a022e0bdaee04b4ee5bbc4R61-R65) [[2]](diffhunk://#diff-796b36f2cafb906a5e95b5750ca5ddc1bf57a304d4a022e0bdaee04b4ee5bbc4R190-R191) These changes collectively improve the reliability and responsiveness of UI updates in the graph node system, especially in scenarios involving external updates, drag-and-drop interactions, and batch widget value changes. https://github.com/user-attachments/assets/8e3194c9-196c-4e13-ad0b-a32177f2d062 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6514-Drag-vuenodes-input-29e6d73d3650817da1b7ef96b61b752d) by [Unito](https://www.unito.io)
412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
|
|
|
|
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
|
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
|
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
|
import { useNodePaste } from '@/composables/node/useNodePaste'
|
|
import { t } from '@/i18n'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import type {
|
|
IBaseWidget,
|
|
IStringWidget
|
|
} from '@/lib/litegraph/src/types/widgets'
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
import {
|
|
getResourceURL,
|
|
splitFilePath
|
|
} from '@/renderer/extensions/vueNodes/widgets/utils/audioUtils'
|
|
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
|
import type { DOMWidget } from '@/scripts/domWidget'
|
|
import { useAudioService } from '@/services/audioService'
|
|
import { type NodeLocatorId } from '@/types'
|
|
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
|
|
|
import { api } from '../../scripts/api'
|
|
import { app } from '../../scripts/app'
|
|
|
|
async function uploadFile(
|
|
audioWidget: IStringWidget,
|
|
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
|
file: File,
|
|
updateNode: boolean,
|
|
pasted: boolean = false
|
|
) {
|
|
try {
|
|
// Wrap file in formdata so it includes filename
|
|
const body = new FormData()
|
|
body.append('image', file)
|
|
if (pasted) body.append('subfolder', 'pasted')
|
|
const resp = await api.fetchApi('/upload/image', {
|
|
method: 'POST',
|
|
body
|
|
})
|
|
|
|
if (resp.status === 200) {
|
|
const data = await resp.json()
|
|
// Add the file to the dropdown list and update the widget value
|
|
let path = data.name
|
|
if (data.subfolder) path = data.subfolder + '/' + path
|
|
|
|
// @ts-expect-error fixme ts strict error
|
|
if (!audioWidget.options.values.includes(path)) {
|
|
// @ts-expect-error fixme ts strict error
|
|
audioWidget.options.values.push(path)
|
|
}
|
|
|
|
if (updateNode) {
|
|
audioUIWidget.element.src = api.apiURL(
|
|
getResourceURL(...splitFilePath(path))
|
|
)
|
|
audioWidget.value = path
|
|
|
|
// Manually trigger the callback to update VueNodes
|
|
audioWidget.callback?.(path)
|
|
}
|
|
} else {
|
|
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
|
}
|
|
} catch (error) {
|
|
// @ts-expect-error fixme ts strict error
|
|
useToastStore().addAlert(error)
|
|
}
|
|
}
|
|
|
|
// AudioWidget MUST be registered first, as AUDIOUPLOAD depends on AUDIO_UI to be
|
|
// present.
|
|
app.registerExtension({
|
|
name: 'Comfy.AudioWidget',
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (
|
|
[
|
|
'LoadAudio',
|
|
'SaveAudio',
|
|
'PreviewAudio',
|
|
'SaveAudioMP3',
|
|
'SaveAudioOpus'
|
|
].includes(
|
|
// @ts-expect-error fixme ts strict error
|
|
nodeType.prototype.comfyClass
|
|
)
|
|
) {
|
|
// @ts-expect-error fixme ts strict error
|
|
nodeData.input.required.audioUI = ['AUDIO_UI', {}]
|
|
}
|
|
},
|
|
getCustomWidgets() {
|
|
return {
|
|
AUDIO_UI(node: LGraphNode, inputName: string) {
|
|
const audio = document.createElement('audio')
|
|
audio.controls = true
|
|
audio.classList.add('comfy-audio')
|
|
audio.setAttribute('name', 'media')
|
|
|
|
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
|
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
|
audioUIWidget.serialize = false
|
|
const { nodeData } = node.constructor
|
|
if (nodeData == null) throw new TypeError('nodeData is null')
|
|
|
|
const isOutputNode = nodeData.output_node
|
|
if (isOutputNode) {
|
|
// Hide the audio widget when there is no audio initially.
|
|
audioUIWidget.element.classList.add('empty-audio-widget')
|
|
// Populate the audio widget UI on node execution.
|
|
const onExecuted = node.onExecuted
|
|
node.onExecuted = function (message: any) {
|
|
// @ts-expect-error fixme ts strict error
|
|
onExecuted?.apply(this, arguments)
|
|
const audios = message.audio
|
|
if (!audios) return
|
|
const audio = audios[0]
|
|
audioUIWidget.element.src = api.apiURL(
|
|
getResourceURL(audio.subfolder, audio.filename, audio.type)
|
|
)
|
|
audioUIWidget.element.classList.remove('empty-audio-widget')
|
|
}
|
|
}
|
|
|
|
audioUIWidget.onRemove = useChainCallback(
|
|
audioUIWidget.onRemove,
|
|
() => {
|
|
if (!audioUIWidget.element) return
|
|
audioUIWidget.element.pause()
|
|
audioUIWidget.element.src = ''
|
|
audioUIWidget.element.remove()
|
|
}
|
|
)
|
|
|
|
return { widget: audioUIWidget }
|
|
}
|
|
}
|
|
},
|
|
onNodeOutputsUpdated(nodeOutputs: Record<NodeLocatorId, any>) {
|
|
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
|
|
if ('audio' in output) {
|
|
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
|
|
if (!node) continue
|
|
|
|
// @ts-expect-error fixme ts strict error
|
|
const audioUIWidget = node.widgets.find(
|
|
(w) => w.name === 'audioUI'
|
|
) as unknown as DOMWidget<HTMLAudioElement, string>
|
|
const audio = output.audio[0]
|
|
audioUIWidget.element.src = api.apiURL(
|
|
getResourceURL(audio.subfolder, audio.filename, audio.type)
|
|
)
|
|
audioUIWidget.element.classList.remove('empty-audio-widget')
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
app.registerExtension({
|
|
name: 'Comfy.UploadAudio',
|
|
async beforeRegisterNodeDef(_nodeType, nodeData: ComfyNodeDef) {
|
|
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
|
|
nodeData.input.required.upload = ['AUDIOUPLOAD', {}]
|
|
}
|
|
},
|
|
getCustomWidgets() {
|
|
return {
|
|
AUDIOUPLOAD(node, inputName: string) {
|
|
// The widget that allows user to select file.
|
|
// @ts-expect-error fixme ts strict error
|
|
const audioWidget = node.widgets.find(
|
|
(w) => w.name === 'audio'
|
|
) as IStringWidget
|
|
// @ts-expect-error fixme ts strict error
|
|
const audioUIWidget = node.widgets.find(
|
|
(w) => w.name === 'audioUI'
|
|
) as unknown as DOMWidget<HTMLAudioElement, string>
|
|
audioUIWidget.options.canvasOnly = true
|
|
|
|
const onAudioWidgetUpdate = () => {
|
|
if (typeof audioWidget.value !== 'string') return
|
|
audioUIWidget.element.src = api.apiURL(
|
|
getResourceURL(...splitFilePath(audioWidget.value))
|
|
)
|
|
}
|
|
// Initially load default audio file to audioUIWidget.
|
|
if (audioWidget.value) {
|
|
onAudioWidgetUpdate()
|
|
}
|
|
audioWidget.callback = onAudioWidgetUpdate
|
|
|
|
// Load saved audio file widget values if restoring from workflow
|
|
const onGraphConfigured = node.onGraphConfigured
|
|
node.onGraphConfigured = function () {
|
|
// @ts-expect-error fixme ts strict error
|
|
onGraphConfigured?.apply(this, arguments)
|
|
if (audioWidget.value) {
|
|
onAudioWidgetUpdate()
|
|
}
|
|
}
|
|
|
|
const handleUpload = async (files: File[]) => {
|
|
if (files?.length) {
|
|
uploadFile(audioWidget, audioUIWidget, files[0], true)
|
|
}
|
|
return files
|
|
}
|
|
|
|
const isAudioFile = (file: File) => file.type.startsWith('audio/')
|
|
|
|
const { openFileSelection } = useNodeFileInput(node, {
|
|
accept: 'audio/*',
|
|
onSelect: handleUpload
|
|
})
|
|
|
|
// The widget to pop up the upload dialog.
|
|
const uploadWidget = node.addWidget(
|
|
'button',
|
|
inputName,
|
|
'',
|
|
openFileSelection,
|
|
{ serialize: false, canvasOnly: true }
|
|
)
|
|
uploadWidget.label = t('g.choose_file_to_upload')
|
|
|
|
useNodeDragAndDrop(node, {
|
|
fileFilter: isAudioFile,
|
|
onDrop: handleUpload
|
|
})
|
|
|
|
useNodePaste(node, {
|
|
fileFilter: isAudioFile,
|
|
onPaste: handleUpload
|
|
})
|
|
|
|
node.previewMediaType = 'audio'
|
|
|
|
return { widget: uploadWidget }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
app.registerExtension({
|
|
name: 'Comfy.RecordAudio',
|
|
|
|
getCustomWidgets() {
|
|
return {
|
|
AUDIO_RECORD(node, inputName: string) {
|
|
const audio = document.createElement('audio')
|
|
audio.controls = true
|
|
audio.classList.add('comfy-audio')
|
|
audio.setAttribute('name', 'media')
|
|
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
|
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
|
audioUIWidget.options.canvasOnly = false
|
|
|
|
let mediaRecorder: MediaRecorder | null = null
|
|
let isRecording = false
|
|
let audioChunks: Blob[] = []
|
|
let currentStream: MediaStream | null = null
|
|
let recordWidget: IBaseWidget | null = null
|
|
|
|
let stopPromise: Promise<void> | null = null
|
|
let stopResolve: (() => void) | null = null
|
|
|
|
audioUIWidget.serializeValue = async () => {
|
|
if (isRecording && mediaRecorder) {
|
|
stopPromise = new Promise((resolve) => {
|
|
stopResolve = resolve
|
|
})
|
|
|
|
mediaRecorder.stop()
|
|
|
|
await stopPromise
|
|
}
|
|
|
|
const audioSrc = audioUIWidget.element.src
|
|
|
|
if (!audioSrc) {
|
|
useToastStore().addAlert(t('g.noAudioRecorded'))
|
|
return ''
|
|
}
|
|
|
|
const blob = await fetch(audioSrc).then((r) => r.blob())
|
|
|
|
return await useAudioService().convertBlobToFileAndSubmit(blob)
|
|
}
|
|
|
|
recordWidget = node.addWidget(
|
|
'button',
|
|
inputName,
|
|
'',
|
|
async () => {
|
|
if (!isRecording) {
|
|
try {
|
|
currentStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: true
|
|
})
|
|
|
|
mediaRecorder = new ExtendableMediaRecorder(currentStream, {
|
|
mimeType: 'audio/wav'
|
|
}) as unknown as MediaRecorder
|
|
|
|
audioChunks = []
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
audioChunks.push(event.data)
|
|
}
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
|
|
|
|
useAudioService().stopAllTracks(currentStream)
|
|
|
|
if (
|
|
audioUIWidget.element.src &&
|
|
audioUIWidget.element.src.startsWith('blob:')
|
|
) {
|
|
URL.revokeObjectURL(audioUIWidget.element.src)
|
|
}
|
|
|
|
audioUIWidget.element.src = URL.createObjectURL(audioBlob)
|
|
|
|
isRecording = false
|
|
|
|
if (recordWidget) {
|
|
recordWidget.label = t('g.startRecording')
|
|
}
|
|
|
|
if (stopResolve) {
|
|
stopResolve()
|
|
stopResolve = null
|
|
stopPromise = null
|
|
}
|
|
}
|
|
|
|
mediaRecorder.onerror = (event) => {
|
|
console.error('MediaRecorder error:', event)
|
|
useAudioService().stopAllTracks(currentStream)
|
|
isRecording = false
|
|
|
|
if (recordWidget) {
|
|
recordWidget.label = t('g.startRecording')
|
|
}
|
|
|
|
if (stopResolve) {
|
|
stopResolve()
|
|
stopResolve = null
|
|
stopPromise = null
|
|
}
|
|
}
|
|
|
|
mediaRecorder.start()
|
|
isRecording = true
|
|
if (recordWidget) {
|
|
recordWidget.label = t('g.stopRecording')
|
|
}
|
|
} catch (err) {
|
|
console.error('Error accessing microphone:', err)
|
|
useToastStore().addAlert(t('g.micPermissionDenied'))
|
|
|
|
if (mediaRecorder) {
|
|
try {
|
|
mediaRecorder.stop()
|
|
} catch {}
|
|
}
|
|
useAudioService().stopAllTracks(currentStream)
|
|
currentStream = null
|
|
isRecording = false
|
|
if (recordWidget) {
|
|
recordWidget.label = t('g.startRecording')
|
|
}
|
|
}
|
|
} else if (mediaRecorder && isRecording) {
|
|
mediaRecorder.stop()
|
|
}
|
|
},
|
|
{ serialize: false, canvasOnly: false }
|
|
)
|
|
|
|
recordWidget.label = t('g.startRecording')
|
|
// Override the type for Vue rendering while keeping 'button' for LiteGraph
|
|
recordWidget.type = 'audiorecord'
|
|
|
|
const originalOnRemoved = node.onRemoved
|
|
node.onRemoved = function () {
|
|
if (isRecording && mediaRecorder) {
|
|
mediaRecorder.stop()
|
|
}
|
|
useAudioService().stopAllTracks(currentStream)
|
|
if (audioUIWidget.element.src?.startsWith('blob:')) {
|
|
URL.revokeObjectURL(audioUIWidget.element.src)
|
|
}
|
|
originalOnRemoved?.call(this)
|
|
}
|
|
|
|
return { widget: recordWidget }
|
|
}
|
|
}
|
|
},
|
|
|
|
async nodeCreated(node) {
|
|
if (node.constructor.comfyClass !== 'RecordAudio') return
|
|
|
|
await useAudioService().registerWavEncoder()
|
|
}
|
|
})
|