Vuenodes/audio widgets (#5627)

This pull request introduces a new audio playback widget for node UIs
and integrates it into the node widget system. The main changes include
the implementation of the `WidgetAudioUI` component, its registration in
the widget registry, and updates to pass node data to the new widget.
Additionally, some logging was added for debugging purposes.

**Audio Widget Implementation and Integration:**

* Added a new `WidgetAudioUI.vue` component that provides audio playback
controls (play/pause, progress slider, volume, options) and loads audio
files from the server based on node data.
* Registered the new `WidgetAudioUI` component in the widget registry by
importing it and adding an entry for the `audioUI` type.
[[1]](diffhunk://#diff-c2a60954f7fdf638716fa1f83e437774d5250e9c99f3aa83c84a1c0e9cc5769bR21)
[[2]](diffhunk://#diff-c2a60954f7fdf638716fa1f83e437774d5250e9c99f3aa83c84a1c0e9cc5769bR112-R115)
* Updated `NodeWidgets.vue` to pass `nodeInfo` as the `node-data` prop
to widgets of type `audioUI`, enabling the widget to access
node-specific audio file information.

**Debugging and Logging:**

* Added logging of `nodeData` in `LGraphNode.vue` and
`WidgetAudioUI.vue` to help with debugging and understanding the data
structure.
[[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R188-R189)
[[2]](diffhunk://#diff-71cce190d74c6b5359288857ab9917caededb8cdf1a7e6377578b78aa32be2fcR1-R284)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5627-Vuenodes-audio-widgets-2716d73d365081fbbc06c1e6cf4ebf4d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Arjan Singh <1598641+arjansingh@users.noreply.github.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Johnpaul Chiwetelu
2025-10-10 05:29:06 +01:00
committed by GitHub
parent 4404c0461d
commit d7796fcda4
18 changed files with 1305 additions and 67 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -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<HTMLAudioElement, string>,
@@ -123,7 +100,6 @@ app.registerExtension({
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')
@@ -199,6 +175,7 @@ app.registerExtension({
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
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<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.options.canvasOnly = true
let mediaRecorder: MediaRecorder | null = null
let isRecording = false

View File

@@ -79,6 +79,7 @@ export type IWidget =
| ISelectButtonWidget
| ITextareaWidget
| IAssetWidget
| IAudioRecordWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -227,6 +228,11 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
value: string
}
export interface IAudioRecordWidget extends IBaseWidget<string, 'audiorecord'> {
type: 'audiorecord'
value: string
}
export interface IAssetWidget
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
type: 'asset'

View File

@@ -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",

View File

@@ -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)
}

View File

@@ -0,0 +1,60 @@
<template>
<div class="w-full">
<WidgetSelect v-model="modelValue" :widget />
<div class="my-4">
<AudioPreviewPlayer
:audio-url="audioUrlFromWidget"
:readonly="readonly"
:hide-when-empty="isOutputNodeRef"
:show-options-button="true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { isOutputNode } from '@/utils/nodeFilterUtil'
import { getAudioUrlFromPath } from '../utils/audioUtils'
import WidgetSelect from './WidgetSelect.vue'
import AudioPreviewPlayer from './audio/AudioPreviewPlayer.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
readonly?: boolean
nodeId: string
}>()
const modelValue = defineModel<string>('modelValue')
defineEmits<{
'update:modelValue': [value: string]
}>()
// Get litegraph node
const litegraphNode = computed(() => {
if (!props.nodeId || !app.rootGraph) return null
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
})
// Check if this is an output node (PreviewAudio, SaveAudio, etc)
const isOutputNodeRef = computed(() => {
const node = litegraphNode.value
if (!node) return false
return isOutputNode(node)
})
const audioFilePath = computed(() => props.widget.value as string)
// Computed audio URL from widget value (for input files)
const audioUrlFromWidget = computed(() => {
const path = audioFilePath.value
if (!path) return ''
return getAudioUrlFromPath(path, 'input')
})
</script>

View File

@@ -0,0 +1,320 @@
<template>
<div class="relative">
<div class="mb-4">
<Button
class="w-[413px] border-0 bg-zinc-500/10 text-zinc-400 dark-theme:bg-charcoal-600 dark-theme:text-white"
:disabled="isRecording || readonly"
@click="handleStartRecording"
>
{{ t('g.startRecording', 'Start Recording') }}
<i-lucide:mic class="ml-1" />
</Button>
</div>
<div
v-if="isRecording || isPlaying || recordedURL"
class="flex h-14 w-[413px] items-center gap-4 rounded-lg bg-zinc-500/10 px-4 text-zinc-400 dark-theme:bg-node-component-surface dark-theme:text-white"
>
<!-- Recording Status -->
<div class="flex min-w-30 items-center gap-2">
<span class="min-w-20 text-xs">
{{
isRecording
? t('g.listening', 'Listening...')
: isPlaying
? t('g.playing', 'Playing...')
: recordedURL
? t('g.ready', 'Ready')
: ''
}}
</span>
<span class="min-w-10 text-sm">{{ formatTime(timer) }}</span>
</div>
<!-- Waveform Visualization -->
<div class="flex h-8 flex-1 items-center gap-2 overflow-x-clip">
<div
v-for="(bar, index) in waveformBars"
:key="index"
class="max-h-8 min-h-1 w-0.75 rounded-[1.5px] bg-slate-100 transition-all duration-100"
:style="{ height: bar.height + 'px' }"
:title="`Bar ${index + 1}: ${bar.height}px`"
/>
</div>
<!-- Control Button -->
<button
v-if="isRecording"
:title="t('g.stopRecording', 'Stop Recording')"
class="flex size-8 animate-pulse items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
@click="handleStopRecording"
>
<div class="size-2.5 rounded-sm bg-[#C02323]" />
</button>
<button
v-else-if="!isRecording && recordedURL && !isPlaying"
:title="t('g.playRecording') || 'Play Recording'"
class="flex size-8 items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
@click="handlePlayRecording"
>
<i
class="icon-[lucide--play] size-4 text-zinc-400 dark-theme:text-white"
/>
</button>
<button
v-else-if="isPlaying"
:title="t('g.stopPlayback') || 'Stop Playback'"
class="flex size-8 items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
@click="handleStopPlayback"
>
<i
class="icon-[lucide--square] size-4 text-zinc-400 dark-theme:text-white"
/>
</button>
</div>
<audio
v-if="recordedURL"
ref="audioRef"
:key="audioElementKey"
:src="recordedURL"
class="hidden"
@ended="playback.onPlaybackEnded"
@loadedmetadata="playback.onMetadataLoaded"
/>
<LODFallback />
</div>
</template>
<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import { Button } from 'primevue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { app } from '@/scripts/app'
import { useAudioService } from '@/services/audioService'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
import { formatTime } from '../utils/audioUtils'
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
readonly?: boolean
modelValue: string
nodeId: string
}>()
// Audio element ref
const audioRef = ref<HTMLAudioElement>()
// Keep track of the last uploaded path as a backup
let lastUploadedPath = ''
// Composables
const recorder = useAudioRecorder({
onRecordingComplete: handleRecordingComplete,
onError: () => {
useToastStore().addAlert(
t('g.micPermissionDenied') || 'Microphone permission denied'
)
}
})
const waveform = useAudioWaveform({
barCount: 18,
minHeight: 4,
maxHeight: 32
})
const playback = useAudioPlayback(audioRef, {
onPlaybackEnded: handlePlaybackEnded,
onMetadataLoaded: (duration) => {
if (!isPlaying.value && !isRecording.value) {
timer.value = Math.floor(duration)
}
}
})
// Timer for recording
const timer = ref(0)
const { pause: pauseTimer, resume: resumeTimer } = useIntervalFn(
() => {
timer.value += 1
},
1000,
{ immediate: false }
)
// Destructure for template access
const { isRecording, recordedURL } = recorder
const { waveformBars } = waveform
const { isPlaying, audioElementKey } = playback
// Computed for waveform animation
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
const { localValue, onChange } = useStringWidgetValue(
props.widget as SimplifiedWidget<string, Record<string, string>>,
props.modelValue,
emit
)
const litegraphNode = computed(() => {
if (!props.nodeId || !app.rootGraph) return null
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
})
async function handleRecordingComplete(blob: Blob) {
try {
const path = await useAudioService().convertBlobToFileAndSubmit(blob)
localValue.value = path
lastUploadedPath = path
onChange(path)
} catch (e) {
useToastStore().addAlert('Failed to upload recorded audio')
}
}
async function handleStartRecording() {
if (props.readonly) return
try {
await waveform.setupAudioContext()
await recorder.startRecording()
// Setup waveform visualization for recording
if (recorder.mediaRecorder.value) {
const stream = recorder.mediaRecorder.value.stream
if (stream) {
await waveform.setupRecordingVisualization(stream)
}
}
// Start timer
timer.value = 0
resumeTimer()
waveform.initWaveform()
waveform.updateWaveform(isWaveformActive)
} catch (err) {
console.error('Failed to start recording:', err)
}
}
function handleStopRecording() {
recorder.stopRecording()
pauseTimer()
waveform.stopWaveform()
}
async function handlePlayRecording() {
if (!recordedURL.value) return
// Reset timer
timer.value = 0
// Reset and setup audio element
await playback.resetAudioElement()
// Wait for audio element to be ready
await new Promise((resolve) => setTimeout(resolve, 50))
if (!audioRef.value) return
// Setup waveform visualization for playback
const setupSuccess = await waveform.setupPlaybackVisualization(audioRef.value)
if (!setupSuccess) return
// Start playback
await playback.play()
// Update waveform
waveform.initWaveform()
waveform.updateWaveform(isWaveformActive)
// Update timer from audio current time
const timerInterval = setInterval(() => {
timer.value = Math.floor(playback.getCurrentTime())
}, 100)
// Store interval for cleanup
playback.playbackTimerInterval.value = timerInterval
}
function handleStopPlayback() {
playback.stop()
handlePlaybackEnded()
}
function handlePlaybackEnded() {
waveform.stopWaveform()
// Clear playback timer interval
if (playback.playbackTimerInterval.value !== null) {
clearInterval(playback.playbackTimerInterval.value)
playback.playbackTimerInterval.value = null
}
const duration = playback.getDuration()
if (duration) {
timer.value = Math.floor(duration)
} else {
timer.value = 0
}
}
// Serialization function for workflow execution
async function serializeValue() {
if (isRecording.value && recorder.mediaRecorder.value) {
recorder.mediaRecorder.value.stop()
await new Promise((resolve, reject) => {
let attempts = 0
const maxAttempts = 50 // 5 seconds max (50 * 100ms)
const checkRecording = () => {
if (!isRecording.value && props.modelValue) {
resolve(undefined)
} else if (++attempts >= maxAttempts) {
reject(new Error('Recording serialization timeout after 5 seconds'))
} else {
setTimeout(checkRecording, 100)
}
}
checkRecording()
})
}
return props.modelValue || lastUploadedPath || ''
}
function registerWidgetSerialization() {
const node = litegraphNode.value
if (!node?.widgets) return
const targetWidget = node.widgets.find((w: IBaseWidget) => w.name === 'audio')
if (targetWidget) {
targetWidget.serializeValue = serializeValue
}
}
onMounted(() => {
waveform.initWaveform()
registerWidgetSerialization()
})
onUnmounted(() => {
if (playback.playbackTimerInterval.value !== null) {
clearInterval(playback.playbackTimerInterval.value)
playback.playbackTimerInterval.value = null
}
})
</script>

View File

@@ -0,0 +1,393 @@
<template>
<div class="relative">
<div
v-if="!hidden"
:class="
cn(
'bg-zinc-500/10 dark-theme:bg-charcoal-600 box-border flex gap-4 items-center justify-start relative rounded-lg w-full h-16 px-4 py-0',
{ hidden: hideWhenEmpty && !hasAudio }
)
"
>
<!-- Hidden audio element -->
<audio
ref="audioRef"
@loadedmetadata="handleLoadedMetadata"
@timeupdate="handleTimeUpdate"
@ended="handleEnded"
/>
<!-- Left Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Play/Pause Button -->
<div
role="button"
:tabindex="0"
aria-label="Play/Pause"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
@click="togglePlayPause"
>
<i
v-if="!isPlaying"
class="icon-[lucide--play] size-4 text-gray-600 dark-theme:text-gray-800"
/>
<i
v-else
class="icon-[lucide--pause] size-4 text-gray-600 dark-theme:text-gray-800"
/>
</div>
<!-- Time Display -->
<div
class="text-sm font-normal text-nowrap text-black dark-theme:text-white"
>
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
</div>
<!-- Progress Bar -->
<div
class="relative h-0.5 flex-1 rounded-full bg-gray-300 dark-theme:bg-stone-200"
>
<div
class="absolute top-0 left-0 h-full rounded-full bg-gray-600 transition-all dark-theme:bg-white/50"
:style="{ width: `${progressPercentage}%` }"
/>
<input
type="range"
:value="progressPercentage"
min="0"
max="100"
step="0.1"
class="absolute inset-0 w-full cursor-pointer opacity-0"
@input="handleSeek"
/>
</div>
<!-- Right Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Volume Button -->
<div
role="button"
:tabindex="0"
aria-label="Volume"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
@click="toggleMute"
>
<i
v-if="showVolumeTwo"
class="icon-[lucide--volume-2] size-4 text-gray-600 dark-theme:text-gray-800"
/>
<i
v-else-if="showVolumeOne"
class="icon-[lucide--volume-1] size-4 text-gray-600 dark-theme:text-gray-800"
/>
<i
v-else
class="icon-[lucide--volume-x] size-4 text-gray-600 dark-theme:text-gray-800"
/>
</div>
<!-- Options Button -->
<div
v-if="showOptionsButton"
ref="optionsButtonRef"
role="button"
:tabindex="0"
aria-label="More Options"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
@click="toggleOptionsMenu"
>
<i
class="icon-[lucide--more-vertical] size-4 text-gray-600 dark-theme:text-gray-800"
/>
</div>
</div>
<!-- Options Menu -->
<TieredMenu
ref="optionsMenu"
:model="menuItems"
popup
class="audio-player-menu"
pt:root:class="!bg-white dark-theme:!bg-charcoal-800 !border-sand-100 dark-theme:!border-charcoal-600"
pt:submenu:class="!bg-white dark-theme:!bg-charcoal-800"
>
<template #item="{ item }">
<div v-if="item.key === 'volume'" class="w-48 px-4 py-2">
<label
class="mb-2 block text-xs text-black dark-theme:text-white"
>{{ item.label }}</label
>
<Slider
:model-value="volume * 10"
:min="0"
:max="10"
:step="1"
class="w-full"
@update:model-value="handleVolumeChange"
/>
</div>
<div
v-else
class="flex cursor-pointer items-center px-4 py-2 text-xs hover:bg-white/10"
@click="item.onClick?.()"
>
<span class="text-black dark-theme:text-white">{{
item.label
}}</span>
<i
v-if="item.selected"
class="ml-auto icon-[lucide--check] size-4 text-black dark-theme:text-white"
/>
</div>
</template>
</TieredMenu>
</div>
<LODFallback />
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import TieredMenu from 'primevue/tieredmenu'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { getLocatorIdFromNodeData } from '@/utils/graphTraversalUtil'
import { isOutputNode } from '@/utils/nodeFilterUtil'
import { cn } from '@/utils/tailwindUtil'
import { formatTime, getResourceURL } from '../../utils/audioUtils'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
readonly?: boolean
hideWhenEmpty?: boolean
showOptionsButton?: boolean
modelValue?: string
nodeId?: string
audioUrl?: string
}>(),
{
hideWhenEmpty: true
}
)
// Refs
const audioRef = ref<HTMLAudioElement>()
const optionsMenu = ref()
const optionsButtonRef = ref<HTMLElement>()
const isPlaying = ref(false)
const isMuted = ref(false)
const volume = ref(1)
const currentTime = ref(0)
const duration = ref(0)
const hasAudio = ref(false)
const playbackRate = ref(1)
// Computed
const progressPercentage = computed(() => {
if (!duration.value || duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
const showVolumeTwo = computed(() => !isMuted.value && volume.value > 0.5)
const showVolumeOne = computed(() => isMuted.value && volume.value > 0)
const litegraphNode = computed(() => {
if (!props.nodeId || !app.rootGraph) return null
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
})
const hidden = computed(() => {
if (!litegraphNode.value) return false
// dont show if its a LoadAudio and we have nodeId
const isLoadAudio =
litegraphNode.value.constructor?.comfyClass === 'LoadAudio'
return isLoadAudio && !!props.nodeId
})
// Check if this is an output node
const isOutputNodeRef = computed(() => {
const node = litegraphNode.value
return !!node && isOutputNode(node)
})
const nodeLocatorId = computed(() => {
const node = litegraphNode.value
if (!node) return null
return getLocatorIdFromNodeData(node)
})
const nodeOutputStore = useNodeOutputStore()
// Computed audio URL from node output (for output nodes)
const audioUrlFromOutput = computed(() => {
if (!isOutputNodeRef.value || !nodeLocatorId.value) return ''
const nodeOutput = nodeOutputStore.nodeOutputs[nodeLocatorId.value]
if (!nodeOutput?.audio || nodeOutput.audio.length === 0) return ''
const audio = nodeOutput.audio[0]
if (!audio.filename) return ''
return api.apiURL(
getResourceURL(
audio.subfolder || '',
audio.filename,
audio.type || 'output'
)
)
})
// Combined audio URL (output takes precedence for output nodes)
const finalAudioUrl = computed(() => {
return audioUrlFromOutput.value || props.audioUrl || ''
})
// Playback controls
const togglePlayPause = () => {
if (!audioRef.value || !audioRef.value.src) {
return
}
if (isPlaying.value) {
audioRef.value.pause()
} else {
void audioRef.value.play()
}
isPlaying.value = !isPlaying.value
}
const toggleMute = () => {
if (audioRef.value) {
isMuted.value = !isMuted.value
audioRef.value.muted = isMuted.value
}
}
const handleSeek = (event: Event) => {
const target = event.target as HTMLInputElement
const value = parseFloat(target.value)
if (audioRef.value && duration.value > 0) {
const newTime = (value / 100) * duration.value
audioRef.value.currentTime = newTime
currentTime.value = newTime
}
}
// Audio events
const handleLoadedMetadata = () => {
if (audioRef.value) {
duration.value = audioRef.value.duration
}
}
const handleTimeUpdate = () => {
if (audioRef.value) {
currentTime.value = audioRef.value.currentTime
}
}
const handleEnded = () => {
isPlaying.value = false
currentTime.value = 0
}
// Options menu
const toggleOptionsMenu = (event: Event) => {
optionsMenu.value?.toggle(event)
}
const setPlaybackSpeed = (speed: number) => {
playbackRate.value = speed
if (audioRef.value) {
audioRef.value.playbackRate = speed
}
}
const handleVolumeChange = (value: number | number[]) => {
const numValue = Array.isArray(value) ? value[0] : value
volume.value = numValue / 10
if (audioRef.value) {
audioRef.value.volume = volume.value
if (volume.value > 0 && isMuted.value) {
isMuted.value = false
audioRef.value.muted = false
}
}
}
const menuItems = computed(() => [
{
label: t('g.playbackSpeed'),
items: [
{
label: t('g.halfSpeed'),
onClick: () => setPlaybackSpeed(0.5),
selected: playbackRate.value === 0.5
},
{
label: t('g.1x'),
onClick: () => setPlaybackSpeed(1),
selected: playbackRate.value === 1
},
{
label: t('g.2x'),
onClick: () => setPlaybackSpeed(2),
selected: playbackRate.value === 2
}
]
},
{
label: t('g.volume'),
key: 'volume'
}
])
// Load audio from URL
const loadAudioFromUrl = (url: string) => {
if (!audioRef.value) return
isPlaying.value = false
audioRef.value.pause()
audioRef.value.src = url
void audioRef.value.load()
hasAudio.value = !!url
}
// Watch for finalAudioUrl changes
watch(
finalAudioUrl,
(newUrl) => {
if (newUrl) {
void nextTick(() => {
loadAudioFromUrl(newUrl)
})
}
},
{ immediate: true }
)
// Cleanup
onUnmounted(() => {
if (audioRef.value) {
audioRef.value.pause()
audioRef.value.src = ''
}
})
</script>
<style scoped>
.audio-player-menu {
--p-tieredmenu-item-focus-background: rgba(255, 255, 255, 0.1);
--p-tieredmenu-item-active-background: rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -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<HTMLAudioElement | undefined>,
options: AudioPlaybackOptions = {}
) {
const isPlaying = ref(false)
const audioElementKey = ref(0)
const playbackTimerInterval = ref<ReturnType<typeof setInterval> | 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
}
}

View File

@@ -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<void>
onError?: (error: Error) => void
}
export function useAudioRecorder(options: AudioRecorderOptions = {}) {
const isRecording = ref(false)
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
const stream = ref<MediaStream | null>(null)
const recordedURL = ref<string | null>(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
}
}

View File

@@ -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<WaveformBar[]>(
Array.from({ length: barCount }, () => ({ height: 16 }))
)
const audioContext = ref<AudioContext | null>(null)
const analyser = ref<AnalyserNode | null>(null)
const dataArray = ref<Uint8Array | null>(null)
const animationId = ref<number | null>(null)
const mediaElementSource = ref<MediaElementAudioSourceNode | null>(null)
function initWaveform() {
waveformBars.value = Array.from({ length: barCount }, () => ({
height: Math.random() * (maxHeight - minHeight) + minHeight
}))
}
function updateWaveform(isActive: Ref<boolean>) {
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<ArrayBuffer>
)
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
}
}

View File

@@ -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
}
}

View File

@@ -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<string, Component> => {
return new Map([['audio', WidgetAudioUI]])
}
// Build lookup maps
const widgets = new Map<string, WidgetDefinition>()
const aliasMap = new Map<string, string>()
@@ -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
}

View File

@@ -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)
]
}

View File

@@ -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<typeof zGalleriaInputSpec>
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
export type AudioRecordInputSpec = z.infer<typeof zAudioRecordInputSpec>
export type InputSpec = z.infer<typeof zInputSpec>
export type OutputSpec = z.infer<typeof zOutputSpec>

View File

@@ -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<string, ComfyWidgetConstructor> = {
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
AUDIO_RECORD: transformWidgetConstructorV2ToV1(useAudioRecordWidget())
}

View File

@@ -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)
})
})
})

View File

@@ -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()
}))