Skip to content

Commit 59af159

Browse files
authored
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 Comfy-Org/ComfyUI#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)
1 parent 533295a commit 59af159

File tree

5 files changed

+169
-141
lines changed

5 files changed

+169
-141
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
2+
import { api } from '@/scripts/api'
3+
import { app } from '@/scripts/app'
4+
import { useExtensionService } from '@/services/extensionService'
5+
6+
useExtensionService().registerExtension({
7+
name: 'Comfy.ImageCompare',
8+
9+
async nodeCreated(node) {
10+
if (node.constructor.comfyClass !== 'ImageCompare') return
11+
12+
const [oldWidth, oldHeight] = node.size
13+
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 350)])
14+
15+
const onExecuted = node.onExecuted
16+
17+
node.onExecuted = function (output: NodeExecutionOutput) {
18+
onExecuted?.call(this, output)
19+
20+
const aImages = (output as Record<string, unknown>).a_images as
21+
| Record<string, string>[]
22+
| undefined
23+
const bImages = (output as Record<string, unknown>).b_images as
24+
| Record<string, string>[]
25+
| undefined
26+
const rand = app.getRandParam()
27+
28+
const beforeUrl =
29+
aImages && aImages.length > 0
30+
? api.apiURL(`/view?${new URLSearchParams(aImages[0])}${rand}`)
31+
: ''
32+
const afterUrl =
33+
bImages && bImages.length > 0
34+
? api.apiURL(`/view?${new URLSearchParams(bImages[0])}${rand}`)
35+
: ''
36+
37+
const widget = node.widgets?.find((w) => w.type === 'imagecompare')
38+
39+
if (widget) {
40+
widget.value = {
41+
before: beforeUrl,
42+
after: afterUrl
43+
}
44+
widget.callback?.(widget.value)
45+
}
46+
}
47+
}
48+
})

src/extensions/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import './electronAdapter'
99
import './groupNode'
1010
import './groupNodeManage'
1111
import './groupOptions'
12+
import './imageCompare'
1213
import './load3d'
1314
import './maskeditor'
1415
import './nodeTemplates'

src/locales/en/main.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,6 +1607,9 @@
16071607
"errorMessage": "Failed to copy to clipboard",
16081608
"errorNotSupported": "Clipboard API not supported in your browser"
16091609
},
1610+
"imageCompare": {
1611+
"noImages": "No images to compare"
1612+
},
16101613
"load3d": {
16111614
"switchCamera": "Switch Camera",
16121615
"showGrid": "Show Grid",
Lines changed: 79 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { mount } from '@vue/test-utils'
2-
import PrimeVue from 'primevue/config'
3-
import ImageCompare from 'primevue/imagecompare'
42
import { describe, expect, it } from 'vitest'
53

64
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -25,8 +23,9 @@ describe('WidgetImageCompare Display', () => {
2523
) => {
2624
return mount(WidgetImageCompare, {
2725
global: {
28-
plugins: [PrimeVue],
29-
components: { ImageCompare }
26+
mocks: {
27+
$t: (key: string) => key
28+
}
3029
},
3130
props: {
3231
widget,
@@ -36,29 +35,23 @@ describe('WidgetImageCompare Display', () => {
3635
}
3736

3837
describe('Component Rendering', () => {
39-
it('renders imagecompare component with proper structure and styling', () => {
38+
it('renders with proper structure and styling when images are provided', () => {
4039
const value: ImageCompareValue = {
4140
before: 'https://example.com/before.jpg',
4241
after: 'https://example.com/after.jpg'
4342
}
4443
const widget = createMockWidget(value)
4544
const wrapper = mountComponent(widget)
4645

47-
// Component exists
48-
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
49-
expect(imageCompare.exists()).toBe(true)
50-
51-
// Renders both images with correct URLs
5246
const images = wrapper.findAll('img')
5347
expect(images).toHaveLength(2)
54-
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
55-
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
5648

57-
// Images have proper styling classes
49+
// In the new implementation: after image is first (background), before image is second (overlay)
50+
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
51+
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
52+
5853
images.forEach((img) => {
59-
expect(img.classes()).toContain('object-cover')
60-
expect(img.classes()).toContain('w-full')
61-
expect(img.classes()).toContain('h-full')
54+
expect(img.classes()).toContain('object-contain')
6255
})
6356
})
6457
})
@@ -74,8 +67,9 @@ describe('WidgetImageCompare Display', () => {
7467
}
7568
const customWrapper = mountComponent(createMockWidget(customAltValue))
7669
const customImages = customWrapper.findAll('img')
77-
expect(customImages[0].attributes('alt')).toBe('Original design')
78-
expect(customImages[1].attributes('alt')).toBe('Updated design')
70+
// DOM order: [after, before]
71+
expect(customImages[0].attributes('alt')).toBe('Updated design')
72+
expect(customImages[1].attributes('alt')).toBe('Original design')
7973

8074
// Test default alt text
8175
const defaultAltValue: ImageCompareValue = {
@@ -84,8 +78,8 @@ describe('WidgetImageCompare Display', () => {
8478
}
8579
const defaultWrapper = mountComponent(createMockWidget(defaultAltValue))
8680
const defaultImages = defaultWrapper.findAll('img')
87-
expect(defaultImages[0].attributes('alt')).toBe('Before image')
88-
expect(defaultImages[1].attributes('alt')).toBe('After image')
81+
expect(defaultImages[0].attributes('alt')).toBe('After image')
82+
expect(defaultImages[1].attributes('alt')).toBe('Before image')
8983

9084
// Test empty string alt text (falls back to default)
9185
const emptyAltValue: ImageCompareValue = {
@@ -96,29 +90,36 @@ describe('WidgetImageCompare Display', () => {
9690
}
9791
const emptyWrapper = mountComponent(createMockWidget(emptyAltValue))
9892
const emptyImages = emptyWrapper.findAll('img')
99-
expect(emptyImages[0].attributes('alt')).toBe('Before image')
100-
expect(emptyImages[1].attributes('alt')).toBe('After image')
93+
expect(emptyImages[0].attributes('alt')).toBe('After image')
94+
expect(emptyImages[1].attributes('alt')).toBe('Before image')
10195
})
10296

103-
it('handles missing and partial image URLs gracefully', () => {
104-
// Missing URLs
105-
const missingValue: ImageCompareValue = { before: '', after: '' }
106-
const missingWrapper = mountComponent(createMockWidget(missingValue))
107-
const missingImages = missingWrapper.findAll('img')
108-
expect(missingImages[0].attributes('src')).toBe('')
109-
expect(missingImages[1].attributes('src')).toBe('')
110-
111-
// Partial URLs
112-
const partialValue: ImageCompareValue = {
97+
it('handles partial image URLs gracefully', () => {
98+
// Only before image provided
99+
const beforeOnlyValue: ImageCompareValue = {
113100
before: 'https://example.com/before.jpg',
114101
after: ''
115102
}
116-
const partialWrapper = mountComponent(createMockWidget(partialValue))
117-
const partialImages = partialWrapper.findAll('img')
118-
expect(partialImages[0].attributes('src')).toBe(
103+
const beforeOnlyWrapper = mountComponent(
104+
createMockWidget(beforeOnlyValue)
105+
)
106+
const beforeOnlyImages = beforeOnlyWrapper.findAll('img')
107+
expect(beforeOnlyImages).toHaveLength(1)
108+
expect(beforeOnlyImages[0].attributes('src')).toBe(
119109
'https://example.com/before.jpg'
120110
)
121-
expect(partialImages[1].attributes('src')).toBe('')
111+
112+
// Only after image provided
113+
const afterOnlyValue: ImageCompareValue = {
114+
before: '',
115+
after: 'https://example.com/after.jpg'
116+
}
117+
const afterOnlyWrapper = mountComponent(createMockWidget(afterOnlyValue))
118+
const afterOnlyImages = afterOnlyWrapper.findAll('img')
119+
expect(afterOnlyImages).toHaveLength(1)
120+
expect(afterOnlyImages[0].attributes('src')).toBe(
121+
'https://example.com/after.jpg'
122+
)
122123
})
123124
})
124125

@@ -129,121 +130,54 @@ describe('WidgetImageCompare Display', () => {
129130
const wrapper = mountComponent(widget)
130131

131132
const images = wrapper.findAll('img')
133+
expect(images).toHaveLength(1)
132134
expect(images[0].attributes('src')).toBe('https://example.com/single.jpg')
133-
expect(images[1].attributes('src')).toBe('')
134-
})
135-
136-
it('uses default alt text for string values', () => {
137-
const value = 'https://example.com/single.jpg'
138-
const widget = createMockWidget(value)
139-
const wrapper = mountComponent(widget)
140-
141-
const images = wrapper.findAll('img')
142135
expect(images[0].attributes('alt')).toBe('Before image')
143-
expect(images[1].attributes('alt')).toBe('After image')
144-
})
145-
})
146-
147-
describe('Widget Options Handling', () => {
148-
it('passes through accessibility options', () => {
149-
const value: ImageCompareValue = {
150-
before: 'https://example.com/before.jpg',
151-
after: 'https://example.com/after.jpg'
152-
}
153-
const widget = createMockWidget(value, {
154-
tabindex: 1,
155-
ariaLabel: 'Compare images',
156-
ariaLabelledby: 'compare-label'
157-
})
158-
const wrapper = mountComponent(widget)
159-
160-
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
161-
expect(imageCompare.props('tabindex')).toBe(1)
162-
expect(imageCompare.props('ariaLabel')).toBe('Compare images')
163-
expect(imageCompare.props('ariaLabelledby')).toBe('compare-label')
164-
})
165-
166-
it('uses default tabindex when not provided', () => {
167-
const value: ImageCompareValue = {
168-
before: 'https://example.com/before.jpg',
169-
after: 'https://example.com/after.jpg'
170-
}
171-
const widget = createMockWidget(value)
172-
const wrapper = mountComponent(widget)
173-
174-
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
175-
expect(imageCompare.props('tabindex')).toBe(0)
176-
})
177-
178-
it('passes through PrimeVue specific options', () => {
179-
const value: ImageCompareValue = {
180-
before: 'https://example.com/before.jpg',
181-
after: 'https://example.com/after.jpg'
182-
}
183-
const widget = createMockWidget(value, {
184-
unstyled: true,
185-
pt: { root: { class: 'custom-class' } },
186-
ptOptions: { mergeSections: true }
187-
})
188-
const wrapper = mountComponent(widget)
189-
190-
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
191-
expect(imageCompare.props('unstyled')).toBe(true)
192-
expect(imageCompare.props('pt')).toEqual({
193-
root: { class: 'custom-class' }
194-
})
195-
expect(imageCompare.props('ptOptions')).toEqual({ mergeSections: true })
196136
})
197137
})
198138

199139
describe('Readonly Mode', () => {
200-
it('renders normally in readonly mode (no interaction restrictions)', () => {
140+
it('renders normally in readonly mode', () => {
201141
const value: ImageCompareValue = {
202142
before: 'https://example.com/before.jpg',
203143
after: 'https://example.com/after.jpg'
204144
}
205145
const widget = createMockWidget(value)
206146
const wrapper = mountComponent(widget, true)
207147

208-
// ImageCompare is display-only, readonly doesn't affect rendering
209-
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
210-
expect(imageCompare.exists()).toBe(true)
211-
212148
const images = wrapper.findAll('img')
213149
expect(images).toHaveLength(2)
214150
})
215151
})
216152

217153
describe('Edge Cases', () => {
218-
it('handles null or undefined widget value', () => {
154+
it('shows no images message when widget value is empty string', () => {
219155
const widget = createMockWidget('')
220156
const wrapper = mountComponent(widget)
221157

222158
const images = wrapper.findAll('img')
223-
expect(images[0].attributes('src')).toBe('')
224-
expect(images[1].attributes('src')).toBe('')
225-
expect(images[0].attributes('alt')).toBe('Before image')
226-
expect(images[1].attributes('alt')).toBe('After image')
159+
expect(images).toHaveLength(0)
160+
expect(wrapper.text()).toContain('imageCompare.noImages')
227161
})
228162

229-
it('handles empty object value', () => {
230-
const value: ImageCompareValue = {} as ImageCompareValue
163+
it('shows no images message when both URLs are empty', () => {
164+
const value: ImageCompareValue = { before: '', after: '' }
231165
const widget = createMockWidget(value)
232166
const wrapper = mountComponent(widget)
233167

234168
const images = wrapper.findAll('img')
235-
expect(images[0].attributes('src')).toBe('')
236-
expect(images[1].attributes('src')).toBe('')
169+
expect(images).toHaveLength(0)
170+
expect(wrapper.text()).toContain('imageCompare.noImages')
237171
})
238172

239-
it('handles malformed object value', () => {
240-
const value = { randomProp: 'test', before: '', after: '' }
173+
it('shows no images message for empty object value', () => {
174+
const value: ImageCompareValue = {} as ImageCompareValue
241175
const widget = createMockWidget(value)
242176
const wrapper = mountComponent(widget)
243177

244178
const images = wrapper.findAll('img')
245-
expect(images[0].attributes('src')).toBe('')
246-
expect(images[1].attributes('src')).toBe('')
179+
expect(images).toHaveLength(0)
180+
expect(wrapper.text()).toContain('imageCompare.noImages')
247181
})
248182

249183
it('handles special content - long URLs, special characters, and long alt text', () => {
@@ -290,7 +224,7 @@ describe('WidgetImageCompare Display', () => {
290224
})
291225

292226
describe('Template Structure', () => {
293-
it('correctly assigns images to left and right template slots', () => {
227+
it('correctly renders after image as background and before image as overlay', () => {
294228
const value: ImageCompareValue = {
295229
before: 'https://example.com/before.jpg',
296230
after: 'https://example.com/after.jpg'
@@ -299,10 +233,11 @@ describe('WidgetImageCompare Display', () => {
299233
const wrapper = mountComponent(widget)
300234

301235
const images = wrapper.findAll('img')
302-
// First image (before) should be in left template slot
303-
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
304-
// Second image (after) should be in right template slot
305-
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
236+
// After image is rendered first as background
237+
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
238+
// Before image is rendered second as overlay with clipPath
239+
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
240+
expect(images[1].classes()).toContain('absolute')
306241
})
307242
})
308243

@@ -333,4 +268,27 @@ describe('WidgetImageCompare Display', () => {
333268
expect(blobUrlImages[1].attributes('src')).toBe(blobUrl)
334269
})
335270
})
271+
272+
describe('Slider Element', () => {
273+
it('renders slider divider when images are present', () => {
274+
const value: ImageCompareValue = {
275+
before: 'https://example.com/before.jpg',
276+
after: 'https://example.com/after.jpg'
277+
}
278+
const widget = createMockWidget(value)
279+
const wrapper = mountComponent(widget)
280+
281+
const slider = wrapper.find('[role="presentation"]')
282+
expect(slider.exists()).toBe(true)
283+
expect(slider.classes()).toContain('bg-white')
284+
})
285+
286+
it('does not render slider when no images', () => {
287+
const widget = createMockWidget('')
288+
const wrapper = mountComponent(widget)
289+
290+
const slider = wrapper.find('[role="presentation"]')
291+
expect(slider.exists()).toBe(false)
292+
})
293+
})
336294
})

0 commit comments

Comments
 (0)