Files
ComfyUI_frontend/src/composables/node/useNodeAnimatedImage.ts
Christian Byrne 0c44129bb4 fix: prevent animated preview duplication on Vue↔Litegraph switch (#9938)
## Problem
SaveAnimatedPNG/WEBP nodes show duplicate output previews when switching
between Vue and Litegraph renderer modes.

## Root Cause
The `ANIM_PREVIEW_WIDGET` (`$$comfy_animation_preview`) DOM widget
lacked `canvasOnly: true`, so `shouldRenderAsVue()` in the widget
registry included it in Vue mode rendering. This caused both:
1. Vue's `ImagePreview.vue` (via `nodeMedia` computed from
`nodeOutputStore`)
2. The legacy `ANIM_PREVIEW_WIDGET` DOM widget (rendered as `WidgetDOM`)

to display simultaneously — duplicating the output preview.

## Fix
Add `canvasOnly: true` to the `ANIM_PREVIEW_WIDGET` options, matching
the pattern used by `IMAGE_PREVIEW` widget in
`useImagePreviewWidget.ts`. This ensures the legacy widget is filtered
out in Vue mode by `shouldRenderAsVue()`, leaving `ImagePreview.vue` as
the single source of truth.

## Testing
- All 539 vueNodes tests pass
- All 22 nodeOutputStore tests pass
- All 140 composables/node tests pass
- Typecheck passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9938-fix-prevent-animated-preview-duplication-on-Vue-Litegraph-switch-3246d73d365081019bbfd7e33a9c14fb)
by [Unito](https://www.unito.io)
2026-03-16 03:07:03 -07:00

80 lines
2.3 KiB
TypeScript

import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
import { isDOMWidget } from '@/scripts/domWidget'
/**
* Composable for handling animated image previews in nodes
*/
export function useNodeAnimatedImage() {
/**
* Shows animated image preview for a node
* @param node The graph node to show the preview for
*/
function showAnimatedPreview(node: LGraphNode) {
if (!node.imgs?.length) return
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === ANIM_PREVIEW_WIDGET
)
node.imgs[0].classList.value = 'block size-full object-contain'
if (widgetIdx > -1) {
// Replace content in existing widget
const widget = node.widgets[widgetIdx]
if (!isDOMWidget(widget)) return
widget.element.replaceChildren(node.imgs[0])
} else {
// Create new widget
const element = document.createElement('div')
element.appendChild(node.imgs[0])
const widget = node.addDOMWidget(ANIM_PREVIEW_WIDGET, 'img', element, {
hideOnZoom: false,
canvasOnly: true
})
node.overIndex = 0
// Add event listeners for canvas interactions
const { handleWheel, handlePointer, forwardEventToCanvas } =
useCanvasInteractions()
node.imgs[0].style.pointerEvents = 'none'
element.addEventListener('wheel', handleWheel)
element.addEventListener('pointermove', handlePointer)
element.addEventListener('pointerup', handlePointer)
element.addEventListener(
'pointerdown',
(e) => {
return e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)
},
true
)
widget.serialize = false
widget.serializeValue = () => undefined
}
}
/**
* Removes animated image preview from a node
* @param node The graph node to remove the preview from
*/
function removeAnimatedPreview(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === ANIM_PREVIEW_WIDGET
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showAnimatedPreview,
removeAnimatedPreview
}
}