mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
5 Commits
codex/fix-
...
fix/glsl-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2acc7668 | ||
|
|
549dd5d36c | ||
|
|
21b25f485e | ||
|
|
eec4e237d6 | ||
|
|
8d230066c8 |
@@ -606,6 +606,45 @@ test.describe(
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
})
|
||||
|
||||
test('Promoted preview renders when outputs are injected for interior node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
// Simulate backend sending execution results for interior SaveImage
|
||||
// node (id 10) inside the SubgraphNode (id 5).
|
||||
// The executed event handler converts execution ID "5:10" to a
|
||||
// NodeLocatorId using the subgraph UUID, then sets both
|
||||
// app.nodeOutputs and the reactive nodeOutputs ref.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.dispatchCustomEvent('executed', {
|
||||
prompt_id: 'test-prompt-id',
|
||||
node: '5:10',
|
||||
display_node: '5:10',
|
||||
output: {
|
||||
images: [
|
||||
{
|
||||
filename: 'example.png',
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The promoted preview should render an image inside the SubgraphNode
|
||||
const previewImage = subgraphVueNode.locator('.image-preview img')
|
||||
await expect(previewImage).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Nested Promoted Widget Disabled State', () => {
|
||||
|
||||
@@ -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 })
|
||||
@@ -235,6 +294,38 @@ describe(usePromotedPreviews, () => {
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('re-evaluates when promotion is added after initial mount', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
|
||||
// Mount BEFORE any promotions exist
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
|
||||
// Add promotion AFTER mount
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
// Seed outputs
|
||||
const mockUrls = ['/view?filename=output.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(mockUrls)
|
||||
|
||||
// The computed should re-evaluate and return the preview
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores non-$$ promoted widgets', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
@@ -253,7 +344,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
|
||||
|
||||
@@ -15,24 +15,35 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
|
||||
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
|
||||
|
||||
/**
|
||||
* Monotonic counter incremented on every mutation. Dependents read this
|
||||
* inside their computed/watch to guarantee re-evaluation even when the
|
||||
* nested Map proxies don't propagate change notifications reliably.
|
||||
*/
|
||||
const _version = ref(0)
|
||||
function _touch() {
|
||||
_version.value++
|
||||
}
|
||||
|
||||
function _getPromotionsForGraph(
|
||||
graphId: UUID
|
||||
): Map<NodeId, PromotionEntry[]> {
|
||||
const promotions = graphPromotions.value.get(graphId)
|
||||
let promotions = graphPromotions.value.get(graphId)
|
||||
if (promotions) return promotions
|
||||
|
||||
const nextPromotions = new Map<NodeId, PromotionEntry[]>()
|
||||
graphPromotions.value.set(graphId, nextPromotions)
|
||||
return nextPromotions
|
||||
graphPromotions.value.set(graphId, new Map<NodeId, PromotionEntry[]>())
|
||||
// Re-read through the reactive proxy so callers get the tracked version
|
||||
promotions = graphPromotions.value.get(graphId)!
|
||||
return promotions
|
||||
}
|
||||
|
||||
function _getRefCountsForGraph(graphId: UUID): Map<string, number> {
|
||||
const refCounts = graphRefCounts.value.get(graphId)
|
||||
let refCounts = graphRefCounts.value.get(graphId)
|
||||
if (refCounts) return refCounts
|
||||
|
||||
const nextRefCounts = new Map<string, number>()
|
||||
graphRefCounts.value.set(graphId, nextRefCounts)
|
||||
return nextRefCounts
|
||||
graphRefCounts.value.set(graphId, new Map<string, number>())
|
||||
refCounts = graphRefCounts.value.get(graphId)!
|
||||
return refCounts
|
||||
}
|
||||
|
||||
function _makeKey(interiorNodeId: string, widgetName: string): string {
|
||||
@@ -64,6 +75,8 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId
|
||||
): PromotionEntry[] {
|
||||
// Read version to establish reactive dependency
|
||||
void _version.value
|
||||
return (
|
||||
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
|
||||
)
|
||||
@@ -112,6 +125,7 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
} else {
|
||||
promotions.set(subgraphNodeId, [...entries])
|
||||
}
|
||||
_touch()
|
||||
}
|
||||
|
||||
function promote(
|
||||
@@ -171,11 +185,13 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
|
||||
// Reordering does not change membership, so ref-counts remain valid.
|
||||
promotions.set(subgraphNodeId, entries)
|
||||
_touch()
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphPromotions.value.delete(graphId)
|
||||
graphRefCounts.value.delete(graphId)
|
||||
_touch()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user