mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
fix: track nodePreviewImages in usePromotedPreviews (#10165)
## Summary The `computed` in `usePromotedPreviews` only tracked `nodeOutputs` as a reactive dependency. GLSL live previews (and other preview-only sources) write to `nodePreviewImages` instead of `nodeOutputs`, so promoted preview widgets on SubgraphNodes never re-evaluated when live previews updated. ## Changes **Production** (`usePromotedPreviews.ts` — 3-line fix): - Add `nodePreviewImages[locatorId]` as a second reactive dependency alongside `nodeOutputs[locatorId]` - Guard now passes when *either* source has data, not just `nodeOutputs` **Tests** (`usePromotedPreviews.test.ts`): - Add `nodePreviewImages` to mock store type and factory - Add `seedPreviewImages()` helper - Add `getNodeImageUrls.mockReset()` in `beforeEach` for proper test isolation - Two new test cases: - `returns preview when only nodePreviewImages exist (e.g. GLSL live preview)` - `recomputes when preview images are populated after first evaluation` - Clean up existing tests to use hoisted `getNodeImageUrls` mock directly instead of `vi.mocked(useNodeOutputStore().getNodeImageUrls)` ## What this supersedes This is a minimal re-implementation of #9461. That PR also modified `promotionStore.ts` with a `_version`/`_touch()` monotonic counter to manually force reactivity — that approach is dropped here as it is an anti-pattern (manually managing reactivity counters instead of using Vue's built-in reactivity system). The promotionStore changes were not needed for this fix. ## Related - Supersedes #9461 - Prerequisite work: #9198 (add GLSLShader to canvas image preview node types) - Upstream feature: #9201 (useGLSLPreview composable) - Adjacent: #9435 (centralize node image rendering state in NodeImageStore) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10165-fix-track-nodePreviewImages-in-usePromotedPreviews-for-GLSL-live-preview-propagation-3266d73d365081cd87d0d12c4c041907) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
12
src/composables/node/canvasImagePreviewTypes.ts
Normal file
12
src/composables/node/canvasImagePreviewTypes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
|
||||
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
|
||||
'PreviewImage',
|
||||
'SaveImage',
|
||||
'GLSLShader'
|
||||
])
|
||||
|
||||
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
|
||||
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
|
||||
|
||||
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
|
||||
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
|
||||
'PreviewImage',
|
||||
'SaveImage',
|
||||
'GLSLShader'
|
||||
])
|
||||
|
||||
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
|
||||
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
|
||||
}
|
||||
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
|
||||
|
||||
/**
|
||||
* Composable for handling canvas image previews in nodes
|
||||
|
||||
@@ -16,7 +16,7 @@ import { usePromotedPreviews } from './usePromotedPreviews'
|
||||
|
||||
type MockNodeOutputStore = Pick<
|
||||
ReturnType<typeof useNodeOutputStore>,
|
||||
'nodeOutputs' | 'getNodeImageUrls'
|
||||
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
|
||||
>
|
||||
|
||||
const getNodeImageUrls = vi.hoisted(() =>
|
||||
@@ -35,6 +35,7 @@ vi.mock('@/stores/nodeOutputStore', () => {
|
||||
function createMockNodeOutputStore(): MockNodeOutputStore {
|
||||
return {
|
||||
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
|
||||
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
|
||||
getNodeImageUrls
|
||||
}
|
||||
}
|
||||
@@ -71,12 +72,24 @@ function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
|
||||
}
|
||||
}
|
||||
|
||||
function seedPreviewImages(
|
||||
subgraphId: string,
|
||||
entries: Array<{ nodeId: number | string; urls: string[] }>
|
||||
) {
|
||||
const store = useNodeOutputStore()
|
||||
for (const { nodeId, urls } of entries) {
|
||||
const locatorId = createNodeLocatorId(subgraphId, nodeId)
|
||||
store.nodePreviewImages[locatorId] = urls
|
||||
}
|
||||
}
|
||||
|
||||
describe(usePromotedPreviews, () => {
|
||||
let nodeOutputStore: MockNodeOutputStore
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
getNodeImageUrls.mockReset()
|
||||
|
||||
nodeOutputStore = createMockNodeOutputStore()
|
||||
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
|
||||
@@ -119,7 +132,7 @@ describe(usePromotedPreviews, () => {
|
||||
|
||||
const mockUrls = ['/view?filename=output.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
|
||||
getNodeImageUrls.mockReturnValue(mockUrls)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
@@ -143,9 +156,7 @@ describe(usePromotedPreviews, () => {
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
|
||||
'/view?filename=output.webm'
|
||||
])
|
||||
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe('video')
|
||||
@@ -162,9 +173,7 @@ describe(usePromotedPreviews, () => {
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
|
||||
'/view?filename=output.mp3'
|
||||
])
|
||||
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe('audio')
|
||||
@@ -194,13 +203,11 @@ describe(usePromotedPreviews, () => {
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10, 20])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
|
||||
(node: LGraphNode) => {
|
||||
if (node === node10) return ['/view?a=1']
|
||||
if (node === node20) return ['/view?b=2']
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
|
||||
if (node === node10) return ['/view?a=1']
|
||||
if (node === node20) return ['/view?b=2']
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toHaveLength(2)
|
||||
@@ -208,6 +215,58 @@ describe(usePromotedPreviews, () => {
|
||||
expect(promotedPreviews.value[1].urls).toEqual(['/view?b=2'])
|
||||
})
|
||||
|
||||
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
||||
getNodeImageUrls.mockReturnValue([blobUrl])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('recomputes when preview images are populated after first evaluation', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
||||
getNodeImageUrls.mockReturnValue([blobUrl])
|
||||
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('skips interior nodes with no image output', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
@@ -253,7 +312,7 @@ describe(usePromotedPreviews, () => {
|
||||
|
||||
const mockUrls = ['/view?filename=img.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
|
||||
getNodeImageUrls.mockReturnValue(mockUrls)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toHaveLength(1)
|
||||
|
||||
@@ -39,16 +39,18 @@ export function usePromotedPreviews(
|
||||
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
|
||||
if (!interiorNode) continue
|
||||
|
||||
// Read from the reactive nodeOutputs ref to establish Vue
|
||||
// dependency tracking. getNodeImageUrls reads from the
|
||||
// non-reactive app.nodeOutputs, so without this access the
|
||||
// computed would never re-evaluate when outputs change.
|
||||
// Read from both reactive refs to establish Vue dependency
|
||||
// tracking. getNodeImageUrls reads from non-reactive
|
||||
// app.nodeOutputs / app.nodePreviewImages, so without this
|
||||
// access the computed would never re-evaluate.
|
||||
const locatorId = createNodeLocatorId(
|
||||
node.subgraph.id,
|
||||
entry.interiorNodeId
|
||||
)
|
||||
const _reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
if (!_reactiveOutputs?.images?.length) continue
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
|
||||
continue
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
if (!urls?.length) continue
|
||||
|
||||
@@ -264,4 +264,28 @@ describe('promoteRecommendedWidgets', () => {
|
||||
).toBe(true)
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
|
||||
// Simulate loading a saved workflow where proxyWidgets does NOT contain
|
||||
// the $$canvas-image-preview entry (e.g. blueprint authored before the
|
||||
// promotion system, or old workflow save).
|
||||
const subgraph = createTestSubgraph()
|
||||
const glslNode = new LGraphNode('GLSLShader')
|
||||
glslNode.type = 'GLSLShader'
|
||||
subgraph.add(glslNode)
|
||||
|
||||
// Create subgraphNode — constructor calls configure → _internalConfigureAfterSlots
|
||||
// which eagerly registers $$canvas-image-preview for supported node types
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(glslNode.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
supportsVirtualCanvasImagePreview
|
||||
} from '@/composables/node/useNodeCanvasImagePreview'
|
||||
} from '@/composables/node/canvasImagePreviewTypes'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
@@ -38,6 +38,10 @@ import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetVie
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
supportsVirtualCanvasImagePreview
|
||||
} from '@/composables/node/canvasImagePreviewTypes'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -992,6 +996,25 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
this._syncPromotions()
|
||||
|
||||
for (const node of this.subgraph.nodes) {
|
||||
if (!supportsVirtualCanvasImagePreview(node)) continue
|
||||
if (
|
||||
store.isPromoted(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
)
|
||||
continue
|
||||
store.promote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveInputWidget(
|
||||
|
||||
Reference in New Issue
Block a user