feat: add ImageCompare node (#7538)

## Summary

add ImageCompare node, which is high demand among custom nodes, such as
rgthree, we should support as core node

Need BE change https://github.com/comfyanonymous/ComfyUI/pull/11343

## Screenshots (if applicable)



https://github.com/user-attachments/assets/a37bdcd0-de59-4bdd-bfc7-1adbe92f5298

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7538-feat-add-ImageCompare-node-2cb6d73d36508163a7d5f4807aece01a)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2025-12-26 15:27:44 -05:00
committed by GitHub
parent 533295ab76
commit 59af15961f
5 changed files with 175 additions and 147 deletions

View File

@@ -0,0 +1,48 @@
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.ImageCompare',
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'ImageCompare') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 350)])
const onExecuted = node.onExecuted
node.onExecuted = function (output: NodeExecutionOutput) {
onExecuted?.call(this, output)
const aImages = (output as Record<string, unknown>).a_images as
| Record<string, string>[]
| undefined
const bImages = (output as Record<string, unknown>).b_images as
| Record<string, string>[]
| undefined
const rand = app.getRandParam()
const beforeUrl =
aImages && aImages.length > 0
? api.apiURL(`/view?${new URLSearchParams(aImages[0])}${rand}`)
: ''
const afterUrl =
bImages && bImages.length > 0
? api.apiURL(`/view?${new URLSearchParams(bImages[0])}${rand}`)
: ''
const widget = node.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = {
before: beforeUrl,
after: afterUrl
}
widget.callback?.(widget.value)
}
}
}
})

View File

@@ -9,6 +9,7 @@ import './electronAdapter'
import './groupNode'
import './groupNodeManage'
import './groupOptions'
import './imageCompare'
import './load3d'
import './maskeditor'
import './nodeTemplates'

View File

@@ -1607,6 +1607,9 @@
"errorMessage": "Failed to copy to clipboard",
"errorNotSupported": "Clipboard API not supported in your browser"
},
"imageCompare": {
"noImages": "No images to compare"
},
"load3d": {
"switchCamera": "Switch Camera",
"showGrid": "Show Grid",

View File

@@ -1,6 +1,4 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ImageCompare from 'primevue/imagecompare'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -25,8 +23,9 @@ describe('WidgetImageCompare Display', () => {
) => {
return mount(WidgetImageCompare, {
global: {
plugins: [PrimeVue],
components: { ImageCompare }
mocks: {
$t: (key: string) => key
}
},
props: {
widget,
@@ -36,7 +35,7 @@ describe('WidgetImageCompare Display', () => {
}
describe('Component Rendering', () => {
it('renders imagecompare component with proper structure and styling', () => {
it('renders with proper structure and styling when images are provided', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -44,21 +43,15 @@ describe('WidgetImageCompare Display', () => {
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
// Component exists
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
// Renders both images with correct URLs
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
// Images have proper styling classes
// In the new implementation: after image is first (background), before image is second (overlay)
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
images.forEach((img) => {
expect(img.classes()).toContain('object-cover')
expect(img.classes()).toContain('w-full')
expect(img.classes()).toContain('h-full')
expect(img.classes()).toContain('object-contain')
})
})
})
@@ -74,8 +67,9 @@ describe('WidgetImageCompare Display', () => {
}
const customWrapper = mountComponent(createMockWidget(customAltValue))
const customImages = customWrapper.findAll('img')
expect(customImages[0].attributes('alt')).toBe('Original design')
expect(customImages[1].attributes('alt')).toBe('Updated design')
// DOM order: [after, before]
expect(customImages[0].attributes('alt')).toBe('Updated design')
expect(customImages[1].attributes('alt')).toBe('Original design')
// Test default alt text
const defaultAltValue: ImageCompareValue = {
@@ -84,8 +78,8 @@ describe('WidgetImageCompare Display', () => {
}
const defaultWrapper = mountComponent(createMockWidget(defaultAltValue))
const defaultImages = defaultWrapper.findAll('img')
expect(defaultImages[0].attributes('alt')).toBe('Before image')
expect(defaultImages[1].attributes('alt')).toBe('After image')
expect(defaultImages[0].attributes('alt')).toBe('After image')
expect(defaultImages[1].attributes('alt')).toBe('Before image')
// Test empty string alt text (falls back to default)
const emptyAltValue: ImageCompareValue = {
@@ -96,29 +90,36 @@ describe('WidgetImageCompare Display', () => {
}
const emptyWrapper = mountComponent(createMockWidget(emptyAltValue))
const emptyImages = emptyWrapper.findAll('img')
expect(emptyImages[0].attributes('alt')).toBe('Before image')
expect(emptyImages[1].attributes('alt')).toBe('After image')
expect(emptyImages[0].attributes('alt')).toBe('After image')
expect(emptyImages[1].attributes('alt')).toBe('Before image')
})
it('handles missing and partial image URLs gracefully', () => {
// Missing URLs
const missingValue: ImageCompareValue = { before: '', after: '' }
const missingWrapper = mountComponent(createMockWidget(missingValue))
const missingImages = missingWrapper.findAll('img')
expect(missingImages[0].attributes('src')).toBe('')
expect(missingImages[1].attributes('src')).toBe('')
// Partial URLs
const partialValue: ImageCompareValue = {
it('handles partial image URLs gracefully', () => {
// Only before image provided
const beforeOnlyValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: ''
}
const partialWrapper = mountComponent(createMockWidget(partialValue))
const partialImages = partialWrapper.findAll('img')
expect(partialImages[0].attributes('src')).toBe(
const beforeOnlyWrapper = mountComponent(
createMockWidget(beforeOnlyValue)
)
const beforeOnlyImages = beforeOnlyWrapper.findAll('img')
expect(beforeOnlyImages).toHaveLength(1)
expect(beforeOnlyImages[0].attributes('src')).toBe(
'https://example.com/before.jpg'
)
expect(partialImages[1].attributes('src')).toBe('')
// Only after image provided
const afterOnlyValue: ImageCompareValue = {
before: '',
after: 'https://example.com/after.jpg'
}
const afterOnlyWrapper = mountComponent(createMockWidget(afterOnlyValue))
const afterOnlyImages = afterOnlyWrapper.findAll('img')
expect(afterOnlyImages).toHaveLength(1)
expect(afterOnlyImages[0].attributes('src')).toBe(
'https://example.com/after.jpg'
)
})
})
@@ -129,75 +130,14 @@ describe('WidgetImageCompare Display', () => {
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images).toHaveLength(1)
expect(images[0].attributes('src')).toBe('https://example.com/single.jpg')
expect(images[1].attributes('src')).toBe('')
})
it('uses default alt text for string values', () => {
const value = 'https://example.com/single.jpg'
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
})
})
describe('Widget Options Handling', () => {
it('passes through accessibility options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
tabindex: 1,
ariaLabel: 'Compare images',
ariaLabelledby: 'compare-label'
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(1)
expect(imageCompare.props('ariaLabel')).toBe('Compare images')
expect(imageCompare.props('ariaLabelledby')).toBe('compare-label')
})
it('uses default tabindex when not provided', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(0)
})
it('passes through PrimeVue specific options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
unstyled: true,
pt: { root: { class: 'custom-class' } },
ptOptions: { mergeSections: true }
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('unstyled')).toBe(true)
expect(imageCompare.props('pt')).toEqual({
root: { class: 'custom-class' }
})
expect(imageCompare.props('ptOptions')).toEqual({ mergeSections: true })
})
})
describe('Readonly Mode', () => {
it('renders normally in readonly mode (no interaction restrictions)', () => {
it('renders normally in readonly mode', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -205,45 +145,39 @@ describe('WidgetImageCompare Display', () => {
const widget = createMockWidget(value)
const wrapper = mountComponent(widget, true)
// ImageCompare is display-only, readonly doesn't affect rendering
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
})
})
describe('Edge Cases', () => {
it('handles null or undefined widget value', () => {
it('shows no images message when widget value is empty string', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('handles empty object value', () => {
it('shows no images message when both URLs are empty', () => {
const value: ImageCompareValue = { before: '', after: '' }
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('shows no images message for empty object value', () => {
const value: ImageCompareValue = {} as ImageCompareValue
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
})
it('handles malformed object value', () => {
const value = { randomProp: 'test', before: '', after: '' }
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('handles special content - long URLs, special characters, and long alt text', () => {
@@ -290,7 +224,7 @@ describe('WidgetImageCompare Display', () => {
})
describe('Template Structure', () => {
it('correctly assigns images to left and right template slots', () => {
it('correctly renders after image as background and before image as overlay', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -299,10 +233,11 @@ describe('WidgetImageCompare Display', () => {
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
// First image (before) should be in left template slot
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
// Second image (after) should be in right template slot
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
// After image is rendered first as background
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
// Before image is rendered second as overlay with clipPath
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
expect(images[1].classes()).toContain('absolute')
})
})
@@ -333,4 +268,27 @@ describe('WidgetImageCompare Display', () => {
expect(blobUrlImages[1].attributes('src')).toBe(blobUrl)
})
})
describe('Slider Element', () => {
it('renders slider divider when images are present', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const slider = wrapper.find('[role="presentation"]')
expect(slider.exists()).toBe(true)
expect(slider.classes()).toContain('bg-white')
})
it('does not render slider when no images', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget)
const slider = wrapper.find('[role="presentation"]')
expect(slider.exists()).toBe(false)
})
})
})

View File

@@ -1,32 +1,39 @@
<template>
<ImageCompare
:tabindex="widget.options?.tabindex ?? 0"
:aria-label="widget.options?.ariaLabel"
:aria-labelledby="widget.options?.ariaLabelledby"
:pt="widget.options?.pt"
:pt-options="widget.options?.ptOptions"
:unstyled="widget.options?.unstyled"
>
<template #left>
<img
:src="beforeImage"
:alt="beforeAlt"
class="h-full w-full object-cover"
/>
</template>
<template #right>
<div ref="containerRef" class="relative size-full min-h-32 overflow-hidden">
<div v-if="beforeImage || afterImage">
<img
v-if="afterImage"
:src="afterImage"
:alt="afterAlt"
class="h-full w-full object-cover"
draggable="false"
class="size-full object-contain"
/>
</template>
</ImageCompare>
<img
v-if="beforeImage"
:src="beforeImage"
:alt="beforeAlt"
draggable="false"
class="absolute inset-0 size-full object-contain"
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
/>
<div
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 bg-white shadow-md"
:style="{ left: `${sliderPosition}%` }"
role="presentation"
/>
</div>
<div v-else class="flex size-full items-center justify-center">
{{ $t('imageCompare.noImages') }}
</div>
</div>
</template>
<script setup lang="ts">
import ImageCompare from 'primevue/imagecompare'
import { computed } from 'vue'
import { useMouseInElement } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -43,6 +50,17 @@ const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue | string>
}>()
const containerRef = ref<HTMLElement | null>(null)
const sliderPosition = ref(50)
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
watch([elementX, elementWidth, isOutside], ([x, width, outside]) => {
if (!outside && width > 0) {
sliderPosition.value = Math.max(0, Math.min(100, (x / width) * 100))
}
})
const beforeImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.before || ''