diff --git a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png index 6e960b8bb..fe1f041f8 100644 Binary files a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png and b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts-snapshots/vue-nodes-upload-widgets-chromium-linux.png differ diff --git a/src/extensions/core/uploadAudio.ts b/src/extensions/core/uploadAudio.ts index 80e648d4a..bde5af6c1 100644 --- a/src/extensions/core/uploadAudio.ts +++ b/src/extensions/core/uploadAudio.ts @@ -11,7 +11,10 @@ import type { IStringWidget } from '@/lib/litegraph/src/types/widgets' import { useToastStore } from '@/platform/updates/common/toastStore' -import type { ResultItemType } from '@/schemas/apiSchema' +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' @@ -21,32 +24,6 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' import { api } from '../../scripts/api' import { app } from '../../scripts/app' -function splitFilePath(path: string): [string, string] { - const folder_separator = path.lastIndexOf('/') - if (folder_separator === -1) { - return ['', path] - } - return [ - path.substring(0, folder_separator), - path.substring(folder_separator + 1) - ] -} - -function getResourceURL( - subfolder: string, - filename: string, - type: ResultItemType = 'input' -): string { - const params = [ - 'filename=' + encodeURIComponent(filename), - 'type=' + type, - 'subfolder=' + subfolder, - app.getRandParam().substring(1) - ].join('&') - - return `/view?${params}` -} - async function uploadFile( audioWidget: IStringWidget, audioUIWidget: DOMWidget, @@ -123,7 +100,6 @@ app.registerExtension({ const audioUIWidget: DOMWidget = node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio) audioUIWidget.serialize = false - const { nodeData } = node.constructor if (nodeData == null) throw new TypeError('nodeData is null') @@ -199,6 +175,7 @@ app.registerExtension({ const audioUIWidget = node.widgets.find( (w) => w.name === 'audioUI' ) as unknown as DOMWidget + audioUIWidget.options.canvasOnly = true const onAudioWidgetUpdate = () => { audioUIWidget.element.src = api.apiURL( @@ -273,9 +250,9 @@ app.registerExtension({ audio.controls = true audio.classList.add('comfy-audio') audio.setAttribute('name', 'media') - const audioUIWidget: DOMWidget = node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio) + audioUIWidget.options.canvasOnly = true let mediaRecorder: MediaRecorder | null = null let isRecording = false diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 8d77c79e9..02111acef 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -79,6 +79,7 @@ export type IWidget = | ISelectButtonWidget | ITextareaWidget | IAssetWidget + | IAudioRecordWidget export interface IBooleanWidget extends IBaseWidget { type: 'toggle' @@ -227,6 +228,11 @@ export interface ITextareaWidget extends IBaseWidget { value: string } +export interface IAudioRecordWidget extends IBaseWidget { + type: 'audiorecord' + value: string +} + export interface IAssetWidget extends IBaseWidget> { type: 'asset' diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 4701db3f4..371ff3e09 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -182,7 +182,17 @@ "nodeHeaderError": "Node Header Error", "nodeSlotsError": "Node Slots Error", "nodeWidgetsError": "Node Widgets Error", - "frameNodes": "Frame Nodes" + "frameNodes": "Frame Nodes", + "listening": "Listening...", + "ready": "Ready", + "playRecording": "Play Recording", + "playing": "Playing", + "stopPlayback": "Stop Playback", + "playbackSpeed": "Playback Speed", + "volume": "Volume", + "halfSpeed": "0.5x", + "1x": "1x", + "2x": "2x" }, "manager": { "title": "Custom Nodes Manager", diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index cbf746ff4..0d6764e89 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -120,12 +120,14 @@ const processedWidgets = computed((): ProcessedWidget[] => { const result: ProcessedWidget[] = [] for (const widget of widgets) { + // Skip if widget is in the hidden list for this node type if (widget.options?.hidden) continue if (widget.options?.canvasOnly) continue if (!widget.type) continue if (!shouldRenderAsVue(widget)) continue - const vueComponent = getComponent(widget.type) || WidgetInputText + const vueComponent = + getComponent(widget.type, widget.name) || WidgetInputText const slotMetadata = widget.slotMetadata @@ -150,6 +152,9 @@ const processedWidgets = computed((): ProcessedWidget[] => { } const updateHandler = (value: unknown) => { + // Update the widget value directly + widget.value = value as WidgetValue + if (widget.callback) { widget.callback(value) } diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetAudioUI.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetAudioUI.vue new file mode 100644 index 000000000..9c472cdec --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetAudioUI.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue new file mode 100644 index 000000000..666f88fa9 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue @@ -0,0 +1,320 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue b/src/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue new file mode 100644 index 000000000..f520b4b0f --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue @@ -0,0 +1,393 @@ + + + + + diff --git a/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioPlayback.ts b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioPlayback.ts new file mode 100644 index 000000000..95ac717c6 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioPlayback.ts @@ -0,0 +1,80 @@ +import { nextTick, ref } from 'vue' +import type { Ref } from 'vue' + +interface AudioPlaybackOptions { + onPlaybackEnded?: () => void + onMetadataLoaded?: (duration: number) => void +} + +export function useAudioPlayback( + audioRef: Ref, + options: AudioPlaybackOptions = {} +) { + const isPlaying = ref(false) + const audioElementKey = ref(0) + const playbackTimerInterval = ref | null>(null) + + async function play() { + if (!audioRef.value) return false + + try { + await audioRef.value.play() + isPlaying.value = true + return true + } catch (error) { + console.warn('Audio playback failed:', error) + isPlaying.value = false + return false + } + } + + function stop() { + if (audioRef.value) { + audioRef.value.pause() + audioRef.value.currentTime = 0 + } + isPlaying.value = false + if (options.onPlaybackEnded) { + options.onPlaybackEnded() + } + } + + function onPlaybackEnded() { + isPlaying.value = false + if (options.onPlaybackEnded) { + options.onPlaybackEnded() + } + } + + function onMetadataLoaded() { + if (audioRef.value?.duration && options.onMetadataLoaded) { + options.onMetadataLoaded(audioRef.value.duration) + } + } + + async function resetAudioElement() { + audioElementKey.value += 1 + await nextTick() + } + + function getCurrentTime() { + return audioRef.value?.currentTime || 0 + } + + function getDuration() { + return audioRef.value?.duration || 0 + } + + return { + isPlaying, + audioElementKey, + play, + stop, + onPlaybackEnded, + onMetadataLoaded, + resetAudioElement, + getCurrentTime, + getDuration, + playbackTimerInterval + } +} diff --git a/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts new file mode 100644 index 000000000..276c2de0a --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts @@ -0,0 +1,107 @@ +import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder' +import { onUnmounted, ref } from 'vue' + +import { useAudioService } from '@/services/audioService' + +interface AudioRecorderOptions { + onRecordingComplete?: (audioBlob: Blob) => Promise + onError?: (error: Error) => void +} + +export function useAudioRecorder(options: AudioRecorderOptions = {}) { + const isRecording = ref(false) + const mediaRecorder = ref(null) + const audioChunks = ref([]) + const stream = ref(null) + const recordedURL = ref(null) + + async function startRecording() { + try { + // Clean up previous recording + if (recordedURL.value?.startsWith('blob:')) { + URL.revokeObjectURL(recordedURL.value) + } + + // Initialize + audioChunks.value = [] + recordedURL.value = null + + // Register wav encoder and get media stream + await useAudioService().registerWavEncoder() + stream.value = await navigator.mediaDevices.getUserMedia({ audio: true }) + + // Create media recorder + mediaRecorder.value = new ExtendableMediaRecorder(stream.value, { + mimeType: 'audio/wav' + }) as unknown as MediaRecorder + + mediaRecorder.value.ondataavailable = (e) => { + audioChunks.value.push(e.data) + } + + mediaRecorder.value.onstop = async () => { + const blob = new Blob(audioChunks.value, { type: 'audio/wav' }) + + // Create blob URL for preview + if (recordedURL.value?.startsWith('blob:')) { + URL.revokeObjectURL(recordedURL.value) + } + recordedURL.value = URL.createObjectURL(blob) + + // Notify completion + if (options.onRecordingComplete) { + await options.onRecordingComplete(blob) + } + + cleanup() + } + + // Start recording + mediaRecorder.value.start(100) + isRecording.value = true + } catch (err) { + if (options.onError) { + options.onError(err as Error) + } + throw err + } + } + + function stopRecording() { + if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') { + mediaRecorder.value.stop() + } else { + cleanup() + } + } + + function cleanup() { + isRecording.value = false + + if (stream.value) { + stream.value.getTracks().forEach((track) => track.stop()) + stream.value = null + } + } + + function dispose() { + stopRecording() + if (recordedURL.value) { + URL.revokeObjectURL(recordedURL.value) + recordedURL.value = null + } + } + + onUnmounted(() => { + dispose() + }) + + return { + isRecording, + recordedURL, + mediaRecorder, + startRecording, + stopRecording, + dispose + } +} diff --git a/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioWaveform.ts b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioWaveform.ts new file mode 100644 index 000000000..37d541f85 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioWaveform.ts @@ -0,0 +1,145 @@ +import { onUnmounted, ref } from 'vue' +import type { Ref } from 'vue' + +interface WaveformBar { + height: number +} + +interface AudioWaveformOptions { + barCount?: number + minHeight?: number + maxHeight?: number +} + +export function useAudioWaveform(options: AudioWaveformOptions = {}) { + const { barCount = 18, minHeight = 4, maxHeight = 32 } = options + + const waveformBars = ref( + Array.from({ length: barCount }, () => ({ height: 16 })) + ) + const audioContext = ref(null) + const analyser = ref(null) + const dataArray = ref(null) + const animationId = ref(null) + const mediaElementSource = ref(null) + + function initWaveform() { + waveformBars.value = Array.from({ length: barCount }, () => ({ + height: Math.random() * (maxHeight - minHeight) + minHeight + })) + } + + function updateWaveform(isActive: Ref) { + if (!isActive.value) return + + if (analyser.value && dataArray.value) { + updateWaveformFromAudio() + } else { + updateWaveformRandom() + } + + animationId.value = requestAnimationFrame(() => updateWaveform(isActive)) + } + + function updateWaveformFromAudio() { + if (!analyser.value || !dataArray.value) return + + analyser.value.getByteFrequencyData( + dataArray.value as Uint8Array + ) + const samplesPerBar = Math.floor(dataArray.value.length / barCount) + + waveformBars.value = waveformBars.value.map((_, i) => { + let sum = 0 + for (let j = 0; j < samplesPerBar; j++) { + sum += dataArray.value![i * samplesPerBar + j] || 0 + } + const average = sum / samplesPerBar + const normalizedHeight = + (average / 255) * (maxHeight - minHeight) + minHeight + return { height: normalizedHeight } + }) + } + + function updateWaveformRandom() { + waveformBars.value = waveformBars.value.map((bar) => ({ + height: Math.max( + minHeight, + Math.min(maxHeight, bar.height + (Math.random() - 0.5) * 4) + ) + })) + } + + async function setupAudioContext() { + if (audioContext.value && audioContext.value.state !== 'closed') { + await audioContext.value.close() + } + audioContext.value = null + mediaElementSource.value = null + } + + async function setupRecordingVisualization(stream: MediaStream) { + audioContext.value = new window.AudioContext() + analyser.value = audioContext.value.createAnalyser() + const source = audioContext.value.createMediaStreamSource(stream) + source.connect(analyser.value) + + analyser.value.fftSize = 256 + dataArray.value = new Uint8Array(analyser.value.frequencyBinCount) + } + + async function setupPlaybackVisualization(audioElement: HTMLAudioElement) { + if (audioContext.value && audioContext.value.state !== 'closed') { + await audioContext.value.close() + } + + mediaElementSource.value = null + + if (!audioElement) return false + + audioContext.value = new window.AudioContext() + analyser.value = audioContext.value.createAnalyser() + + mediaElementSource.value = + audioContext.value.createMediaElementSource(audioElement) + + mediaElementSource.value.connect(analyser.value) + analyser.value.connect(audioContext.value.destination) + + analyser.value.fftSize = 256 + dataArray.value = new Uint8Array(analyser.value.frequencyBinCount) + + return true + } + + function stopWaveform() { + if (animationId.value) { + cancelAnimationFrame(animationId.value) + animationId.value = null + } + } + + function dispose() { + stopWaveform() + if (audioContext.value && audioContext.value.state !== 'closed') { + void audioContext.value.close() + } + audioContext.value = null + mediaElementSource.value = null + } + + onUnmounted(() => { + dispose() + }) + + return { + waveformBars, + initWaveform, + updateWaveform, + setupAudioContext, + setupRecordingVisualization, + setupPlaybackVisualization, + stopWaveform, + dispose + } +} diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget.ts new file mode 100644 index 000000000..efa11a43b --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget.ts @@ -0,0 +1,24 @@ +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { IAudioRecordWidget } from '@/lib/litegraph/src/types/widgets' +import type { + AudioRecordInputSpec, + InputSpec as InputSpecV2 +} from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' + +export const useAudioRecordWidget = (): ComfyWidgetConstructorV2 => { + return (node: LGraphNode, inputSpec: InputSpecV2): IAudioRecordWidget => { + const { + name, + default: defaultValue = '', + options = {} + } = inputSpec as AudioRecordInputSpec + + const widget = node.addWidget('audiorecord', name, defaultValue, () => {}, { + serialize: true, + ...options + }) as IAudioRecordWidget + + return widget + } +} diff --git a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts index b07c89e5b..8bda86865 100644 --- a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts +++ b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts @@ -3,6 +3,7 @@ */ import type { Component } from 'vue' +import WidgetAudioUI from '../components/WidgetAudioUI.vue' import WidgetButton from '../components/WidgetButton.vue' import WidgetChart from '../components/WidgetChart.vue' import WidgetColorPicker from '../components/WidgetColorPicker.vue' @@ -13,11 +14,13 @@ import WidgetInputNumber from '../components/WidgetInputNumber.vue' import WidgetInputText from '../components/WidgetInputText.vue' import WidgetMarkdown from '../components/WidgetMarkdown.vue' import WidgetMultiSelect from '../components/WidgetMultiSelect.vue' +import WidgetRecordAudio from '../components/WidgetRecordAudio.vue' import WidgetSelect from '../components/WidgetSelect.vue' import WidgetSelectButton from '../components/WidgetSelectButton.vue' import WidgetTextarea from '../components/WidgetTextarea.vue' import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue' import WidgetTreeSelect from '../components/WidgetTreeSelect.vue' +import AudioPreviewPlayer from '../components/audio/AudioPreviewPlayer.vue' interface WidgetDefinition { component: Component @@ -108,9 +111,29 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [ [ 'markdown', { component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false } + ], + [ + 'audiorecord', + { + component: WidgetRecordAudio, + aliases: ['AUDIO_RECORD', 'AUDIORECORD'], + essential: false + } + ], + [ + 'audioUI', + { + component: AudioPreviewPlayer, + aliases: ['AUDIOUI', 'AUDIO_UI'], + essential: false + } ] ] +const getComboWidgetAdditions = (): Map => { + return new Map([['audio', WidgetAudioUI]]) +} + // Build lookup maps const widgets = new Map() const aliasMap = new Map() @@ -125,7 +148,13 @@ for (const [type, def] of coreWidgetDefinitions) { // Utility functions const getCanonicalType = (type: string): string => aliasMap.get(type) || type -export const getComponent = (type: string): Component | null => { +export const getComponent = (type: string, name: string): Component | null => { + if (type == 'combo') { + const comboAdditions = getComboWidgetAdditions() + if (comboAdditions.has(name)) { + return comboAdditions.get(name) || null + } + } const canonicalType = getCanonicalType(type) return widgets.get(canonicalType)?.component || null } diff --git a/src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts b/src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts new file mode 100644 index 000000000..35e0c4482 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts @@ -0,0 +1,54 @@ +import type { ResultItemType } from '@/schemas/apiSchema' +import { api } from '@/scripts/api' + +/** + * Format time in MM:SS format + */ +export function formatTime(seconds: number): string { + if (isNaN(seconds) || seconds === 0) return '0:00' + + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` +} + +/** + * Get full audio URL from path + */ +export function getAudioUrlFromPath( + path: string, + type: ResultItemType = 'input' +): string { + const [subfolder, filename] = splitFilePath(path) + return api.apiURL(getResourceURL(subfolder, filename, type)) +} + +function getRandParam() { + return '&rand=' + Math.random() +} + +export function getResourceURL( + subfolder: string, + filename: string, + type: ResultItemType = 'input' +): string { + const params = [ + 'filename=' + encodeURIComponent(filename), + 'type=' + type, + 'subfolder=' + subfolder, + getRandParam().substring(1) + ].join('&') + + return `/view?${params}` +} + +export function splitFilePath(path: string): [string, string] { + const folder_separator = path.lastIndexOf('/') + if (folder_separator === -1) { + return ['', path] + } + return [ + path.substring(0, folder_separator), + path.substring(folder_separator + 1) + ] +} diff --git a/src/schemas/nodeDef/nodeDefSchemaV2.ts b/src/schemas/nodeDef/nodeDefSchemaV2.ts index 09983d115..f77c59a80 100644 --- a/src/schemas/nodeDef/nodeDefSchemaV2.ts +++ b/src/schemas/nodeDef/nodeDefSchemaV2.ts @@ -152,6 +152,13 @@ const zTextareaInputSpec = zBaseInputOptions.extend({ .optional() }) +const zAudioRecordInputSpec = zBaseInputOptions.extend({ + type: z.literal('AUDIORECORD'), + name: z.string(), + isOptional: z.boolean().optional(), + options: z.record(z.unknown()).optional() +}) + const zCustomInputSpec = zBaseInputOptions.extend({ type: z.string(), name: z.string(), @@ -167,6 +174,7 @@ const zInputSpec = z.union([ zColorInputSpec, zFileUploadInputSpec, zImageInputSpec, + zAudioRecordInputSpec, zImageCompareInputSpec, zMarkdownInputSpec, zTreeSelectInputSpec, @@ -222,6 +230,7 @@ export type GalleriaInputSpec = z.infer export type SelectButtonInputSpec = z.infer export type TextareaInputSpec = z.infer export type CustomInputSpec = z.infer +export type AudioRecordInputSpec = z.infer export type InputSpec = z.infer export type OutputSpec = z.infer diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index dd75081af..97fd3f829 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -6,6 +6,7 @@ import type { IStringWidget } from '@/lib/litegraph/src/types/widgets' import { useSettingStore } from '@/platform/settings/settingStore' +import { useAudioRecordWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget' import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget' import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget' import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget' @@ -304,5 +305,6 @@ export const ComfyWidgets: Record = { CHART: transformWidgetConstructorV2ToV1(useChartWidget()), GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()), SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()), - TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()) + TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()), + AUDIO_RECORD: transformWidgetConstructorV2ToV1(useAudioRecordWidget()) } diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts index fe6708a40..773ed8364 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' +import WidgetAudioUI from '@/renderer/extensions/vueNodes/widgets/components/WidgetAudioUI.vue' import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue' import WidgetColorPicker from '@/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue' import WidgetFileUpload from '@/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue' @@ -26,81 +27,81 @@ describe('widgetRegistry', () => { // Test number type mappings describe('number types', () => { it('should map int types to slider widget', () => { - expect(getComponent('int')).toBe(WidgetInputNumber) - expect(getComponent('INT')).toBe(WidgetInputNumber) + expect(getComponent('int', 'bar')).toBe(WidgetInputNumber) + expect(getComponent('INT', 'bar')).toBe(WidgetInputNumber) }) it('should map float types to slider widget', () => { - expect(getComponent('float')).toBe(WidgetInputNumber) - expect(getComponent('FLOAT')).toBe(WidgetInputNumber) - expect(getComponent('number')).toBe(WidgetInputNumber) - expect(getComponent('slider')).toBe(WidgetInputNumber) + expect(getComponent('float', 'cfg')).toBe(WidgetInputNumber) + expect(getComponent('FLOAT', 'cfg')).toBe(WidgetInputNumber) + expect(getComponent('number', 'cfg')).toBe(WidgetInputNumber) + expect(getComponent('slider', 'cfg')).toBe(WidgetInputNumber) }) }) // Test text type mappings describe('text types', () => { it('should map text variations to input text widget', () => { - expect(getComponent('text')).toBe(WidgetInputText) - expect(getComponent('string')).toBe(WidgetInputText) - expect(getComponent('STRING')).toBe(WidgetInputText) + expect(getComponent('text', 'text')).toBe(WidgetInputText) + expect(getComponent('string', 'text')).toBe(WidgetInputText) + expect(getComponent('STRING', 'text')).toBe(WidgetInputText) }) it('should map multiline text types to textarea widget', () => { - expect(getComponent('multiline')).toBe(WidgetTextarea) - expect(getComponent('textarea')).toBe(WidgetTextarea) - expect(getComponent('TEXTAREA')).toBe(WidgetTextarea) - expect(getComponent('customtext')).toBe(WidgetTextarea) + expect(getComponent('multiline', 'text')).toBe(WidgetTextarea) + expect(getComponent('textarea', 'text')).toBe(WidgetTextarea) + expect(getComponent('TEXTAREA', 'text')).toBe(WidgetTextarea) + expect(getComponent('customtext', 'text')).toBe(WidgetTextarea) }) it('should map markdown to markdown widget', () => { - expect(getComponent('MARKDOWN')).toBe(WidgetMarkdown) - expect(getComponent('markdown')).toBe(WidgetMarkdown) + expect(getComponent('MARKDOWN', 'text')).toBe(WidgetMarkdown) + expect(getComponent('markdown', 'text')).toBe(WidgetMarkdown) }) }) // Test selection type mappings describe('selection types', () => { it('should map combo types to select widget', () => { - expect(getComponent('combo')).toBe(WidgetSelect) - expect(getComponent('COMBO')).toBe(WidgetSelect) + expect(getComponent('combo', 'image')).toBe(WidgetSelect) + expect(getComponent('COMBO', 'video')).toBe(WidgetSelect) }) }) // Test boolean type mappings describe('boolean types', () => { it('should map boolean types to toggle switch widget', () => { - expect(getComponent('toggle')).toBe(WidgetToggleSwitch) - expect(getComponent('boolean')).toBe(WidgetToggleSwitch) - expect(getComponent('BOOLEAN')).toBe(WidgetToggleSwitch) + expect(getComponent('toggle', 'image')).toBe(WidgetToggleSwitch) + expect(getComponent('boolean', 'image')).toBe(WidgetToggleSwitch) + expect(getComponent('BOOLEAN', 'image')).toBe(WidgetToggleSwitch) }) }) // Test advanced widget mappings describe('advanced widgets', () => { it('should map color types to color picker widget', () => { - expect(getComponent('color')).toBe(WidgetColorPicker) - expect(getComponent('COLOR')).toBe(WidgetColorPicker) + expect(getComponent('color', 'color')).toBe(WidgetColorPicker) + expect(getComponent('COLOR', 'color')).toBe(WidgetColorPicker) }) it('should map file types to file upload widget', () => { - expect(getComponent('file')).toBe(WidgetFileUpload) - expect(getComponent('fileupload')).toBe(WidgetFileUpload) - expect(getComponent('FILEUPLOAD')).toBe(WidgetFileUpload) + expect(getComponent('file', 'file')).toBe(WidgetFileUpload) + expect(getComponent('fileupload', 'file')).toBe(WidgetFileUpload) + expect(getComponent('FILEUPLOAD', 'file')).toBe(WidgetFileUpload) }) it('should map button types to button widget', () => { - expect(getComponent('button')).toBe(WidgetButton) - expect(getComponent('BUTTON')).toBe(WidgetButton) + expect(getComponent('button', '')).toBe(WidgetButton) + expect(getComponent('BUTTON', '')).toBe(WidgetButton) }) }) // Test fallback behavior describe('fallback behavior', () => { it('should return null for unknown types', () => { - expect(getComponent('unknown')).toBe(null) - expect(getComponent('custom_widget')).toBe(null) - expect(getComponent('')).toBe(null) + expect(getComponent('unknown', 'unknown')).toBe(null) + expect(getComponent('custom_widget', 'custom_widget')).toBe(null) + expect(getComponent('', '')).toBe(null) }) }) }) @@ -165,10 +166,16 @@ describe('widgetRegistry', () => { it('should handle case sensitivity correctly through aliases', () => { // Test that both lowercase and uppercase work - expect(getComponent('string')).toBe(WidgetInputText) - expect(getComponent('STRING')).toBe(WidgetInputText) - expect(getComponent('combo')).toBe(WidgetSelect) - expect(getComponent('COMBO')).toBe(WidgetSelect) + expect(getComponent('string', '')).toBe(WidgetInputText) + expect(getComponent('STRING', '')).toBe(WidgetInputText) + expect(getComponent('combo', '')).toBe(WidgetSelect) + expect(getComponent('COMBO', '')).toBe(WidgetSelect) + }) + + it('should handle combo additional widgets', () => { + // Test that both lowercase and uppercase work + expect(getComponent('combo', 'audio')).toBe(WidgetAudioUI) + expect(getComponent('combo', 'image')).toBe(WidgetSelect) }) }) }) diff --git a/vitest.setup.ts b/vitest.setup.ts index 042efb8cd..56b24693e 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import 'vue' // Define global variables for tests @@ -7,3 +8,12 @@ globalThis.__SENTRY_DSN__ = '' globalThis.__ALGOLIA_APP_ID__ = '' globalThis.__ALGOLIA_API_KEY__ = '' globalThis.__USE_PROD_CONFIG__ = false + +// Mock Worker for extendable-media-recorder +globalThis.Worker = vi.fn().mockImplementation(() => ({ + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() +}))