Skip to content

Commit 23d3228

Browse files
Fix: server fails to load SVG outputs when user has "Preview Format" setting specified (#3734)
1 parent 197f33f commit 23d3228

File tree

3 files changed

+143
-7
lines changed

3 files changed

+143
-7
lines changed

src/scripts/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ export class ComfyApp {
273273
useExtensionService().invokeExtensions('onNodeOutputsUpdated', value)
274274
}
275275

276+
/**
277+
* If the user has specified a preferred format to receive preview images in,
278+
* this function will return that format as a url query param.
279+
* If the node's outputs are not images, this param should not be used, as it will
280+
* force the server to load the output file as an image.
281+
*/
276282
getPreviewFormatParam() {
277283
let preview_format = useSettingStore().get('Comfy.PreviewFormat')
278284
if (preview_format) return `&preview=${preview_format}`

src/stores/imagePreviewStore.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
33

44
import { ExecutedWsMessage, ResultItem } from '@/schemas/apiSchema'
55
import { api } from '@/scripts/api'
6+
import { app } from '@/scripts/app'
67
import { parseFilePath } from '@/utils/formatUtil'
78
import { isVideoNode } from '@/utils/litegraphUtil'
89

@@ -17,11 +18,6 @@ const createOutputs = (
1718
}
1819
}
1920

20-
const getPreviewParam = (node: LGraphNode): string => {
21-
if (node.animatedImages || isVideoNode(node)) return ''
22-
return app.getPreviewFormatParam()
23-
}
24-
2521
export const useNodeOutputStore = defineStore('nodeOutput', () => {
2622
const getNodeId = (node: LGraphNode): string => node.id.toString()
2723

@@ -35,6 +31,41 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
3531
return app.nodePreviewImages[getNodeId(node)]
3632
}
3733

34+
/**
35+
* Check if a node's outputs includes images that should/can be loaded normally
36+
* by PIL.
37+
*/
38+
const isImageOutputs = (
39+
node: LGraphNode,
40+
outputs: ExecutedWsMessage['output']
41+
): boolean => {
42+
// If animated webp/png or video outputs, return false
43+
if (node.animatedImages || isVideoNode(node)) return false
44+
45+
// If no images, return false
46+
if (!outputs?.images?.length) return false
47+
48+
// If svg images, return false
49+
if (outputs.images.some((image) => image.filename?.endsWith('svg')))
50+
return false
51+
52+
return true
53+
}
54+
55+
/**
56+
* Get the preview param for the node's outputs.
57+
*
58+
* If the output is an image, use the user's preferred format (from settings).
59+
* For non-image outputs, return an empty string, as including the preview param
60+
* will force the server to load the output file as an image.
61+
*/
62+
function getPreviewParam(
63+
node: LGraphNode,
64+
outputs: ExecutedWsMessage['output']
65+
): string {
66+
return isImageOutputs(node, outputs) ? app.getPreviewFormatParam() : ''
67+
}
68+
3869
function getNodeImageUrls(node: LGraphNode): string[] | undefined {
3970
const previews = getNodePreviews(node)
4071
if (previews?.length) return previews
@@ -43,7 +74,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
4374
if (!outputs?.images?.length) return
4475

4576
const rand = app.getRandParam()
46-
const previewParam = getPreviewParam(node)
77+
const previewParam = getPreviewParam(node, outputs)
4778

4879
return outputs.images.map((image) => {
4980
const imgUrlPart = new URLSearchParams(image)
@@ -78,6 +109,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
78109
getNodeOutputs,
79110
getNodeImageUrls,
80111
getNodePreviews,
81-
setNodeOutputs
112+
setNodeOutputs,
113+
getPreviewParam
82114
}
83115
})
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { LGraphNode } from '@comfyorg/litegraph'
2+
import { createPinia, setActivePinia } from 'pinia'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { ExecutedWsMessage } from '@/schemas/apiSchema'
6+
import { app } from '@/scripts/app'
7+
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
8+
import * as litegraphUtil from '@/utils/litegraphUtil'
9+
10+
vi.mock('@/utils/litegraphUtil', () => ({
11+
isVideoNode: vi.fn()
12+
}))
13+
14+
vi.mock('@/scripts/app', () => ({
15+
app: {
16+
getPreviewFormatParam: vi.fn(() => '&format=test_webp')
17+
}
18+
}))
19+
20+
const createMockNode = (overrides: Partial<LGraphNode> = {}): LGraphNode =>
21+
({
22+
id: 1,
23+
type: 'TestNode',
24+
...overrides
25+
}) as LGraphNode
26+
27+
const createMockOutputs = (
28+
images?: ExecutedWsMessage['output']['images']
29+
): ExecutedWsMessage['output'] => ({ images })
30+
31+
describe('imagePreviewStore getPreviewParam', () => {
32+
beforeEach(() => {
33+
setActivePinia(createPinia())
34+
vi.clearAllMocks()
35+
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false)
36+
})
37+
38+
it('should return empty string if node.animatedImages is true', () => {
39+
const store = useNodeOutputStore()
40+
// @ts-expect-error `animatedImages` property is not typed
41+
const node = createMockNode({ animatedImages: true })
42+
const outputs = createMockOutputs([{ filename: 'img.png' }])
43+
expect(store.getPreviewParam(node, outputs)).toBe('')
44+
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
45+
})
46+
47+
it('should return empty string if isVideoNode returns true', () => {
48+
const store = useNodeOutputStore()
49+
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(true)
50+
const node = createMockNode()
51+
const outputs = createMockOutputs([{ filename: 'img.png' }])
52+
expect(store.getPreviewParam(node, outputs)).toBe('')
53+
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
54+
})
55+
56+
it('should return empty string if outputs.images is undefined', () => {
57+
const store = useNodeOutputStore()
58+
const node = createMockNode()
59+
const outputs: ExecutedWsMessage['output'] = {}
60+
expect(store.getPreviewParam(node, outputs)).toBe('')
61+
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
62+
})
63+
64+
it('should return empty string if outputs.images is empty', () => {
65+
const store = useNodeOutputStore()
66+
const node = createMockNode()
67+
const outputs = createMockOutputs([])
68+
expect(store.getPreviewParam(node, outputs)).toBe('')
69+
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
70+
})
71+
72+
it('should return empty string if outputs.images contains SVG images', () => {
73+
const store = useNodeOutputStore()
74+
const node = createMockNode()
75+
const outputs = createMockOutputs([{ filename: 'img.svg' }])
76+
expect(store.getPreviewParam(node, outputs)).toBe('')
77+
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
78+
})
79+
80+
it('should return format param for standard image outputs', () => {
81+
const store = useNodeOutputStore()
82+
const node = createMockNode()
83+
const outputs = createMockOutputs([{ filename: 'img.png' }])
84+
expect(store.getPreviewParam(node, outputs)).toBe('&format=test_webp')
85+
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
86+
})
87+
88+
it('should return format param for multiple standard images', () => {
89+
const store = useNodeOutputStore()
90+
const node = createMockNode()
91+
const outputs = createMockOutputs([
92+
{ filename: 'img1.png' },
93+
{ filename: 'img2.jpg' }
94+
])
95+
expect(store.getPreviewParam(node, outputs)).toBe('&format=test_webp')
96+
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
97+
})
98+
})

0 commit comments

Comments
 (0)