diff --git a/browser_tests/assets/widgets/linked_core_media.json b/browser_tests/assets/widgets/linked_core_media.json new file mode 100644 index 00000000000..243fa62b12a --- /dev/null +++ b/browser_tests/assets/widgets/linked_core_media.json @@ -0,0 +1,314 @@ +{ + "id": "6db64321-d1c9-4bb5-b6d4-e08b60531fc6", + "revision": 0, + "last_node_id": 10, + "last_link_id": 5, + "nodes": [ + { + "id": 1, + "type": "PrimitiveNode", + "pos": [20, 20], + "size": [250, 82], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "COMBO", + "type": "COMBO", + "widget": { + "name": "image" + }, + "links": [1] + } + ], + "title": "image", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": ["linked-image.webp [output]", "fixed"] + }, + { + "id": 2, + "type": "PrimitiveNode", + "pos": [20, 380], + "size": [250, 82], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "COMBO", + "type": "COMBO", + "widget": { + "name": "file" + }, + "links": [2] + } + ], + "title": "file", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": ["linked-video.mp4", "fixed"] + }, + { + "id": 3, + "type": "PrimitiveNode", + "pos": [20, 740], + "size": [250, 82], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "COMBO", + "type": "COMBO", + "widget": { + "name": "audio" + }, + "links": [3] + } + ], + "title": "audio", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": ["linked-audio.wav", "fixed"] + }, + { + "id": 4, + "type": "LoadImage", + "pos": [360, 20], + "size": [315, 314], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "COMBO", + "widget": { + "name": "image" + }, + "link": 1 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": null + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": ["linked-image.webp", "image"] + }, + { + "id": 5, + "type": "LoadVideo", + "pos": [360, 380], + "size": [315, 314], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "file", + "type": "COMBO", + "widget": { + "name": "file" + }, + "link": 2 + } + ], + "outputs": [ + { + "name": "VIDEO", + "type": "VIDEO", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadVideo" + }, + "widgets_values": ["linked-video.mp4", "image"] + }, + { + "id": 6, + "type": "LoadAudio", + "pos": [360, 740], + "size": [315, 125], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "audio", + "type": "COMBO", + "widget": { + "name": "audio" + }, + "link": 3 + } + ], + "outputs": [ + { + "name": "AUDIO", + "type": "AUDIO", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadAudio" + }, + "widgets_values": ["linked-audio.wav", null, ""] + }, + { + "id": 7, + "type": "PrimitiveNode", + "pos": [720, 20], + "size": [250, 82], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "COMBO", + "type": "COMBO", + "widget": { + "name": "image" + }, + "links": [4] + } + ], + "title": "mask image", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": ["linked-image.webp", "fixed"] + }, + { + "id": 8, + "type": "PrimitiveNode", + "pos": [720, 380], + "size": [250, 82], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "COMBO", + "type": "COMBO", + "widget": { + "name": "image" + }, + "links": [5] + } + ], + "title": "output image", + "properties": { + "Run widget replace on values": false + }, + "widgets_values": ["linked-image.webp", "fixed"] + }, + { + "id": 9, + "type": "LoadImageMask", + "pos": [1060, 20], + "size": [315, 314], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "COMBO", + "widget": { + "name": "image" + }, + "link": 4 + } + ], + "outputs": [ + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImageMask" + }, + "widgets_values": ["linked-image.webp", "alpha", "image"] + }, + { + "id": 10, + "type": "LoadImageOutput", + "pos": [1060, 380], + "size": [315, 314], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "COMBO", + "widget": { + "name": "image" + }, + "link": 5 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": null + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImageOutput" + }, + "widgets_values": [ + "linked-image.webp [output]", + false, + "refresh", + "image" + ] + } + ], + "links": [ + [1, 1, 0, 4, 0, "COMBO"], + [2, 2, 0, 5, 0, "COMBO"], + [3, 3, 0, 6, 0, "COMBO"], + [4, 7, 0, 9, 0, "COMBO"], + [5, 8, 0, 10, 0, "COMBO"] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.8, + "offset": [0, 0] + } + }, + "version": 0.4 +} diff --git a/browser_tests/fixtures/utils/viewFileMocks.ts b/browser_tests/fixtures/utils/viewFileMocks.ts new file mode 100644 index 00000000000..29b57c931bd --- /dev/null +++ b/browser_tests/fixtures/utils/viewFileMocks.ts @@ -0,0 +1,48 @@ +import type { Page, Route } from '@playwright/test' + +type RouteFulfillOptions = NonNullable[0]> + +type ViewFile = Pick + +const transparentPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwPIRwAAAABJRU5ErkJggg==', + 'base64' +) + +export async function mockViewFiles( + page: Page, + filesByName: Readonly> +) { + await page.route('**/api/view**', async (route) => { + if (route.request().method().toUpperCase() !== 'GET') { + await route.fallback() + return + } + + const filename = new URL(route.request().url()).searchParams.get('filename') + if (!filename) { + await route.fulfill({ + status: 400, + json: { error: 'Missing filename' } satisfies { error: string } + }) + return + } + + const file = filesByName[filename] + if (!file) { + await route.fulfill({ + status: 404, + json: { + error: `Unknown filename: ${filename}` + } satisfies { error: string } + }) + return + } + + await route.fulfill({ + body: file.body ?? (file.path ? undefined : transparentPng), + contentType: file.contentType ?? 'image/png', + path: file.path + }) + }) +} diff --git a/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts b/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts index a16a7116c7e..5d9e29ba041 100644 --- a/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts +++ b/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts @@ -10,6 +10,7 @@ import { routeMockJobTimestamp } from '@e2e/fixtures/jobsRouteFixture' import { TestIds } from '@e2e/fixtures/selectors' +import { mockViewFiles } from '@e2e/fixtures/utils/viewFileMocks' import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper' import type { JobDetail, @@ -18,18 +19,6 @@ import type { const test = mergeTests(comfyPageFixture, jobsRouteFixture) -interface ViewFile { - body?: Buffer | string - contentType?: string -} - -type ViewFilesByName = Readonly> - -const transparentPng = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwPIRwAAAABJRU5ErkJggg==', - 'base64' -) - const alphaJob = createRouteMockJob({ id: 'alpha', create_time: routeMockJobTimestamp - 1_000, @@ -161,41 +150,6 @@ async function mockInputFiles(page: Page, files: readonly string[]) { }) } -async function mockViewFiles(page: Page, filesByName: ViewFilesByName) { - await page.route('**/api/view**', async (route) => { - if (route.request().method().toUpperCase() !== 'GET') { - await route.fallback() - return - } - - const url = new URL(route.request().url()) - const filename = url.searchParams.get('filename') - if (!filename) { - await route.fulfill({ - status: 400, - json: { error: 'Missing filename' } satisfies { error: string } - }) - return - } - - const file = filesByName[filename] - if (!file) { - await route.fulfill({ - status: 404, - json: { - error: `Unknown filename: ${filename}` - } satisfies { error: string } - }) - return - } - - await route.fulfill({ - body: file.body ?? transparentPng, - contentType: file.contentType ?? 'image/png' - }) - }) -} - function isGeneratedAssetVerificationResponse(response: Response): boolean { const url = new URL(response.url()) return ( diff --git a/browser_tests/tests/vueNodes/widgets/load/linkedCoreMedia.spec.ts b/browser_tests/tests/vueNodes/widgets/load/linkedCoreMedia.spec.ts new file mode 100644 index 00000000000..059be37506b --- /dev/null +++ b/browser_tests/tests/vueNodes/widgets/load/linkedCoreMedia.spec.ts @@ -0,0 +1,174 @@ +import { AudioPreview, getWav } from '@e2e/fixtures/components/AudioPreview' +import { VideoPreview } from '@e2e/fixtures/components/VideoPreview' +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import { assetPath } from '@e2e/fixtures/utils/paths' +import { mockViewFiles } from '@e2e/fixtures/utils/viewFileMocks' + +const linkedImageActionCases = [ + { nodeType: 'LoadImageMask', title: 'Load Image (as Mask)' }, + { nodeType: 'LoadImageOutput', title: 'Load Image (from Outputs)' } +] as const + +const imageActionNames = ['Open Image', 'Paste Image'] as const + +test.describe('linked core media selectors', { tag: '@vue-nodes' }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.page.route('**/internal/files/output**', async (route) => { + if (route.request().method().toUpperCase() !== 'GET') { + await route.fallback() + return + } + + await route.fulfill({ json: ['linked-image.webp [output]'] }) + }) + + await mockViewFiles(comfyPage.page, { + 'linked-image.webp': { + contentType: 'image/webp', + path: assetPath('image64x64.webp') + }, + 'linked-image.webp [output]': { + contentType: 'image/webp', + path: assetPath('image64x64.webp') + }, + 'linked-video.mp4': { + contentType: 'video/mp4', + path: assetPath('plain_video.mp4') + }, + 'linked-audio.wav': { + body: getWav(), + contentType: 'audio/x-wav' + } + }) + + await comfyPage.workflow.loadWorkflow('widgets/linked_core_media') + }) + + test('hides local media until each selector is disconnected', async ({ + comfyPage + }) => { + const loadImage = await comfyPage.vueNodes.getFixtureByTitle(/^Load Image$/) + const loadImageMask = await comfyPage.vueNodes.getFixtureByTitle( + 'Load Image (as Mask)' + ) + const loadImageOutput = await comfyPage.vueNodes.getFixtureByTitle( + 'Load Image (from Outputs)' + ) + const loadVideo = comfyPage.vueNodes.getNodeByTitle('Load Video') + const loadAudio = comfyPage.vueNodes.getNodeByTitle('Load Audio') + const videoPreview = new VideoPreview(loadVideo) + const audioPreview = new AudioPreview(loadAudio) + const [ + [loadImageNode], + [loadImageMaskNode], + [loadImageOutputNode], + [loadVideoNode], + [loadAudioNode] + ] = await Promise.all( + [ + 'LoadImage', + 'LoadImageMask', + 'LoadImageOutput', + 'LoadVideo', + 'LoadAudio' + ].map((type) => comfyPage.nodeOps.getNodeRefsByType(type)) + ) + + const imageLoaders = [ + { fixture: loadImage, node: loadImageNode }, + { fixture: loadImageMask, node: loadImageMaskNode }, + { fixture: loadImageOutput, node: loadImageOutputNode } + ] + + const contextMenu = comfyPage.page.locator('.p-contextmenu') + + await test.step('hide local media for all linked core loaders', async () => { + for (const { fixture } of imageLoaders) { + await expect(fixture.imagePreview).toHaveCount(0) + } + await expect(videoPreview.preview).toHaveCount(0) + await expect(audioPreview.audio).toHaveCount(0) + }) + + await test.step('hide linked Load Image actions', async () => { + await loadImageNode.centerOnNode() + await comfyPage.contextMenu.openForVueNode(loadImage.header) + await expect(contextMenu).toBeVisible() + await expect( + contextMenu.getByText('Open Image', { exact: true }) + ).toHaveCount(0) + await expect( + contextMenu.getByText('Paste Image', { exact: true }) + ).toHaveCount(0) + await comfyPage.page.keyboard.press('Escape') + }) + + await test.step('restore local media for all disconnected core loaders', async () => { + for (const node of [ + ...imageLoaders.map(({ node }) => node), + loadVideoNode, + loadAudioNode + ]) { + await (await node.getInput(0)).removeLinks() + } + + for (const { fixture } of imageLoaders) { + await expect(fixture.imagePreview).toBeVisible() + await expect(fixture.imagePreview.locator('img')).toBeVisible() + } + await expect(videoPreview.preview).toBeVisible() + await expect(videoPreview.video).toBeVisible() + await expect(audioPreview.play).toBeVisible() + }) + + await test.step('restore disconnected Load Image actions', async () => { + await loadImageNode.centerOnNode() + await comfyPage.contextMenu.openForVueNode(loadImage.header) + await expect(contextMenu).toBeVisible() + await expect( + contextMenu.getByText('Open Image', { exact: true }) + ).toBeVisible() + await expect( + contextMenu.getByText('Paste Image', { exact: true }) + ).toBeVisible() + }) + }) + + for (const { nodeType, title } of linkedImageActionCases) { + test(`hides linked ${title} actions`, async ({ comfyPage }) => { + const fixture = await comfyPage.vueNodes.getFixtureByTitle(title) + const [node] = await comfyPage.nodeOps.getNodeRefsByType(nodeType) + + await node.centerOnNode() + await comfyPage.contextMenu.openForVueNode(fixture.header) + + for (const actionName of imageActionNames) { + await expect( + comfyPage.contextMenu.primeVueMenu.getByText(actionName, { + exact: true + }) + ).toHaveCount(0) + } + }) + + test(`restores disconnected ${title} actions`, async ({ comfyPage }) => { + const fixture = await comfyPage.vueNodes.getFixtureByTitle(title) + const [node] = await comfyPage.nodeOps.getNodeRefsByType(nodeType) + + await (await node.getInput(0)).removeLinks() + await node.centerOnNode() + await comfyPage.contextMenu.openForVueNode(fixture.header) + + for (const actionName of imageActionNames) { + await expect( + comfyPage.contextMenu.primeVueMenu.getByText(actionName, { + exact: true + }) + ).toBeVisible() + } + }) + } +}) diff --git a/src/composables/graph/useImageMenuOptions.test.ts b/src/composables/graph/useImageMenuOptions.test.ts index f1bdeea54b2..039ac4ceeb5 100644 --- a/src/composables/graph/useImageMenuOptions.test.ts +++ b/src/composables/graph/useImageMenuOptions.test.ts @@ -114,6 +114,19 @@ describe('useImageMenuOptions', () => { expect(options.every((o) => !!o.icon)).toBe(true) }) + + it('keeps output preview actions when the local image input is unavailable', () => { + const node = createImageNode() + const { getImageMenuOptions } = useImageMenuOptions() + const labels = getImageMenuOptions(node, { + input: false, + preview: true + }).map((option) => option.label) + + expect(labels).toContain('Open Image') + expect(labels).toContain('Save Image') + expect(labels).not.toContain('Paste Image') + }) }) describe('pasteImage action', () => { diff --git a/src/composables/graph/useImageMenuOptions.ts b/src/composables/graph/useImageMenuOptions.ts index 1c34ddd8970..2a38e6ac405 100644 --- a/src/composables/graph/useImageMenuOptions.ts +++ b/src/composables/graph/useImageMenuOptions.ts @@ -3,9 +3,17 @@ import { useI18n } from 'vue-i18n' import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { useCommandStore } from '@/stores/commandStore' +import type { CoreMediaMenuActionKind } from '@/utils/coreMediaMenuActionUtils' import type { MenuOption } from './useMoreOptionsMenu' +type ImageMenuAvailability = Record + +const DEFAULT_IMAGE_MENU_AVAILABILITY: ImageMenuAvailability = { + input: true, + preview: true +} + function canPasteImage(node?: LGraphNode): boolean { return typeof node?.pasteFiles === 'function' } @@ -107,14 +115,21 @@ export function useImageMenuOptions() { } } - const getImageMenuOptions = (node: LGraphNode): MenuOption[] => { + const getImageMenuOptions = ( + node: LGraphNode, + availability: ImageMenuAvailability = DEFAULT_IMAGE_MENU_AVAILABILITY + ): MenuOption[] => { const hasImages = !!node?.imgs?.length const canPaste = canPasteImage(node) - if (!hasImages && !canPaste) return [] + if ( + (!hasImages || !availability.preview) && + (!canPaste || !availability.input) + ) + return [] const options: MenuOption[] = [] - if (hasImages) { + if (hasImages && availability.preview) { options.push( { label: t('contextMenu.Open Image'), @@ -134,7 +149,7 @@ export function useImageMenuOptions() { ) } - if (canPaste) { + if (canPaste && availability.input) { options.push({ label: t('contextMenu.Paste Image'), icon: 'icon-[lucide--clipboard-paste]', @@ -142,7 +157,7 @@ export function useImageMenuOptions() { }) } - if (hasImages) { + if (hasImages && availability.preview) { options.push({ label: t('contextMenu.Save Image'), icon: 'icon-[lucide--download]', diff --git a/src/composables/graph/useMoreOptionsMenu.ts b/src/composables/graph/useMoreOptionsMenu.ts index e30affb7b88..eec03945aef 100644 --- a/src/composables/graph/useMoreOptionsMenu.ts +++ b/src/composables/graph/useMoreOptionsMenu.ts @@ -3,8 +3,15 @@ import type { Ref } from 'vue' import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { + shouldHideLinkedCoreMediaInputActions, + shouldHideLinkedCoreMediaInputPreview +} from '@/renderer/extensions/vueNodes/utils/linkedCoreMediaUtils' import { getExtraOptionsForWidget } from '@/services/litegraphService' +import { useNodeOutputStore } from '@/stores/nodeOutputStore' import type { SerializedNodeId } from '@/types/nodeId' +import { filterUnavailableCoreMediaMenuActions } from '@/utils/coreMediaMenuActionUtils' +import type { CoreMediaMenuActionKind } from '@/utils/coreMediaMenuActionUtils' import { isLGraphGroup } from '@/utils/litegraphUtil' import { @@ -130,6 +137,7 @@ export function useMoreOptionsMenu() { } = useSelectionState() const canvasStore = useCanvasStore() + const nodeOutputStore = useNodeOutputStore() const { getImageMenuOptions } = useImageMenuOptions() const { @@ -177,6 +185,18 @@ export function useMoreOptionsMenu() { // For single node selection, also get LiteGraph menu items to merge const litegraphOptions: MenuOption[] = [] const node: LGraphNode | undefined = selectedNodes.value[0] + const hideLinkedInputActions = node + ? shouldHideLinkedCoreMediaInputActions(node) + : false + const hideLinkedInputPreview = node + ? shouldHideLinkedCoreMediaInputPreview( + node, + nodeOutputStore.getNodeOutputs(node) + ) + : false + const unavailableCoreMediaActionKinds = new Set() + if (hideLinkedInputActions) unavailableCoreMediaActionKinds.add('input') + if (hideLinkedInputPreview) unavailableCoreMediaActionKinds.add('preview') if ( selectedNodes.value.length === 1 && !groupContext && @@ -186,7 +206,14 @@ export function useMoreOptionsMenu() { const rawItems = canvasStore.canvas.getNodeMenuOptions(node) // Don't apply structuring yet - we'll do it after merging with Vue options litegraphOptions.push( - ...convertContextMenuToOptions(rawItems, node, false) + ...convertContextMenuToOptions( + filterUnavailableCoreMediaMenuActions( + rawItems, + unavailableCoreMediaActionKinds + ), + node, + false + ) ) } catch (error) { console.error('Error getting LiteGraph menu items:', error) @@ -258,7 +285,12 @@ export function useMoreOptionsMenu() { // Section 5: Image operations (if image node) if (hasImageNode.value && selectedNodes.value.length > 0) { - options.push(...getImageMenuOptions(selectedNodes.value[0])) + options.push( + ...getImageMenuOptions(selectedNodes.value[0], { + input: !hideLinkedInputActions, + preview: !hideLinkedInputPreview + }) + ) options.push({ type: 'divider' }) } const [widgetName] = hoveredWidget.value ?? [] diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.test.ts b/src/renderer/extensions/vueNodes/components/LGraphNode.test.ts index 991420acaaf..1bc5dba6b24 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.test.ts +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.test.ts @@ -18,6 +18,7 @@ import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composable import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useSettingStore } from '@/platform/settings/settingStore' import { app } from '@/scripts/app' +import { useNodeOutputStore } from '@/stores/nodeOutputStore' const mockData = vi.hoisted(() => ({ mockExecuting: false, @@ -149,8 +150,14 @@ function renderLGraphNode(props: ComponentProps) { stubs: { NodeHeader: true, NodeSlots: true, - NodeWidgets: true, - NodeContent: true, + NodeWidgets: { + props: ['nodeData'], + template: + '
{{ nodeData.widgets.map((widget) => `${widget.name}:${widget.type}`).join(",") }}
' + }, + NodeContent: { + template: '
' + }, SlotConnectionDot: true } } @@ -180,11 +187,13 @@ const mockRerouteNodeData: VueNodeData = { describe('LGraphNode', () => { beforeEach(() => { mockData.mockExecuting = false + mockData.mockLgraphNode = null setActivePinia(pinia) const canvasStore = useCanvasStore() canvasStore.selectedNodeIds.clear() const settingStore = useSettingStore(pinia) + useNodeOutputStore().nodeOutputs = {} vi.mocked(settingStore.get).mockImplementation((key) => { if (key === 'Comfy.RightSidePanel.ShowErrorsTab') return true if (key === 'Comfy.Node.AlwaysShowAdvancedWidgets') return false @@ -249,6 +258,124 @@ describe('LGraphNode', () => { expect(overlay).toHaveClass('border-node-stroke-executing') }) + it('hides a linked core LoadImage input preview', () => { + mockData.mockLgraphNode = { + constructor: { + comfyClass: 'LoadImage', + nodeData: { isCoreNode: true } + }, + inputs: [{ link: 1, widget: { name: 'image' } }], + isSubgraphNode: () => false + } + const nodeOutputStore = useNodeOutputStore() + nodeOutputStore.nodeOutputs['test-node-123'] = { + images: [{ filename: 'input.png', type: 'input' }] + } + vi.mocked(nodeOutputStore.getNodeImageUrls).mockReturnValue(['/input.png']) + + renderLGraphNode({ + nodeData: { + ...mockNodeData, + type: 'LoadImage', + widgets: [ + { + name: 'image', + type: 'combo', + slotMetadata: { index: 0, linked: true, type: 'STRING' } + } + ] + } + }) + + expect(screen.queryByTestId('node-content')).not.toBeInTheDocument() + }) + + it('keeps an executed output preview when the LoadImage selector is linked', () => { + mockData.mockLgraphNode = { + constructor: { + comfyClass: 'LoadImage', + nodeData: { isCoreNode: true } + }, + inputs: [{ link: 1, widget: { name: 'image' } }], + isSubgraphNode: () => false + } + const nodeOutputStore = useNodeOutputStore() + nodeOutputStore.nodeOutputs['test-node-123'] = { + images: [{ filename: 'output.png', type: 'output' }] + } + vi.mocked(nodeOutputStore.getNodeImageUrls).mockReturnValue(['/output.png']) + + renderLGraphNode({ + nodeData: { + ...mockNodeData, + type: 'LoadImage', + widgets: [ + { + name: 'image', + type: 'combo', + slotMetadata: { index: 0, linked: true, type: 'STRING' } + } + ] + } + }) + + expect(screen.getByTestId('node-content')).toBeInTheDocument() + }) + + it('restores only the core LoadAudio input player on disconnect', async () => { + const audioInput: { + link: number | null + widget: { name: string } + } = { link: 1, widget: { name: 'audio' } } + mockData.mockLgraphNode = { + constructor: { + comfyClass: 'LoadAudio', + nodeData: { isCoreNode: true } + }, + inputs: [audioInput], + isSubgraphNode: () => false + } + const linkedAudioWidget = { + name: 'audio', + type: 'combo', + slotMetadata: { index: 0, linked: true, type: 'STRING' } + } + const audioPlayerWidget = { name: 'audioUI', type: 'audioUI' } + const { rerender } = renderLGraphNode({ + nodeData: { + ...mockNodeData, + type: 'LoadAudio', + widgets: [linkedAudioWidget, audioPlayerWidget] + } + }) + + expect(screen.getByTestId('node-widgets')).not.toHaveTextContent( + 'audioUI:audioUI' + ) + + audioInput.link = null + await rerender({ + nodeData: { + ...mockNodeData, + type: 'LoadAudio', + widgets: [ + { + ...linkedAudioWidget, + slotMetadata: { + ...linkedAudioWidget.slotMetadata, + linked: false + } + }, + audioPlayerWidget + ] + } + }) + + expect(screen.getByTestId('node-widgets')).toHaveTextContent( + 'audioUI:audioUI' + ) + }) + it('should widen the selection outline rounding when the node has an error', () => { const canvasStore = useCanvasStore() canvasStore.selectedNodeIds.add(mockNodeData.id) diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 17b7bb1b11f..9e504196c47 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -153,7 +153,10 @@ > - +
{ return getNodeByLocatorId(app.rootGraph, locatorId) }) +const renderedNodeData = computed(() => { + const node = lgraphNode.value + const widgets = nodeData.widgets ?? [] + if (!node || !shouldHideLinkedCoreLoadAudioPlayer(node, widgets)) + return nodeData + + return { + ...nodeData, + widgets: widgets.filter( + (widget) => widget.name !== 'audioUI' || widget.type !== 'audioUI' + ) + } +}) + // TODO: Surface subgraph info more cleanly in VueNodeData instead of // reaching through lgraphNode for promoted preview resolution. const { promotedPreviews } = usePromotedPreviews(lgraphNode) @@ -780,6 +801,8 @@ const nodeMedia = computed(() => { return undefined if (node instanceof SubgraphNode) return undefined + if (shouldHideLinkedCoreMediaInputPreview(node, newOutputs, nodeData.widgets)) + return undefined const urls = nodeOutputs.getNodeImageUrls(node) if (!urls?.length) return undefined diff --git a/src/renderer/extensions/vueNodes/utils/linkedCoreMediaUtils.test.ts b/src/renderer/extensions/vueNodes/utils/linkedCoreMediaUtils.test.ts new file mode 100644 index 00000000000..cb114303f34 --- /dev/null +++ b/src/renderer/extensions/vueNodes/utils/linkedCoreMediaUtils.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { + createMockLGraphNode, + createMockNodeInputSlot +} from '@/utils/__tests__/litegraphTestUtils' + +import { + shouldHideLinkedCoreLoadAudioPlayer, + shouldHideLinkedCoreMediaInputActions, + shouldHideLinkedCoreMediaInputPreview +} from './linkedCoreMediaUtils' + +interface MediaNodeOptions { + isCoreNode?: boolean + linkedInputName?: string + nodeClass: string +} + +function mediaNode({ + isCoreNode = true, + linkedInputName, + nodeClass +}: MediaNodeOptions): LGraphNode { + const selectorWidget = linkedInputName + ? { name: linkedInputName, options: {}, type: 'combo', y: 0 } + : undefined + const selectorInput = linkedInputName + ? createMockNodeInputSlot({ + name: linkedInputName, + widget: { name: linkedInputName } + }) + : undefined + + return createMockLGraphNode({ + constructor: { + comfyClass: nodeClass, + nodeData: { isCoreNode } + }, + getSlotFromWidget: (widget: unknown) => + widget === selectorWidget ? selectorInput : undefined, + inputs: selectorInput ? [selectorInput] : [], + isInputConnected: vi.fn(() => linkedInputName !== undefined), + widgets: selectorWidget ? [selectorWidget] : [] + }) +} + +function linkedWidget(name: string) { + return { name, slotMetadata: { index: 0, linked: true, type: 'STRING' } } +} + +describe('linked core media loader matching', () => { + it.for([ + { nodeClass: 'LoadImage', selector: 'image' }, + { nodeClass: 'LoadImageMask', selector: 'image' }, + { nodeClass: 'LoadImageOutput', selector: 'image' }, + { nodeClass: 'LoadVideo', selector: 'file' } + ] as const)( + 'matches core $nodeClass by its exact selector', + ({ nodeClass, selector }) => { + const node = mediaNode({ nodeClass }) + + expect( + shouldHideLinkedCoreMediaInputActions(node, [linkedWidget(selector)]) + ).toBe(true) + } + ) + + it('matches core LoadAudio by its exact selector', () => { + const node = mediaNode({ nodeClass: 'LoadAudio' }) + + expect( + shouldHideLinkedCoreLoadAudioPlayer(node, [linkedWidget('audio')]) + ).toBe(true) + }) + + it('excludes a custom node with a core class name', () => { + const node = mediaNode({ + isCoreNode: false, + linkedInputName: 'image', + nodeClass: 'LoadImage' + }) + + expect(shouldHideLinkedCoreMediaInputActions(node)).toBe(false) + expect(shouldHideLinkedCoreLoadAudioPlayer(node)).toBe(false) + }) + + it.for([ + { nodeClass: 'PreviewImage', selector: 'image' }, + { nodeClass: 'SaveImage', selector: 'image' }, + { nodeClass: 'Load3D', selector: 'model_file' }, + { nodeClass: 'Load3DAdvanced', selector: 'model_file' }, + { nodeClass: 'PreviewAudio', selector: 'audio' }, + { nodeClass: 'SaveAudio', selector: 'audio' }, + { nodeClass: 'RecordAudio', selector: 'audio' } + ])( + 'excludes the other core node class $nodeClass', + ({ nodeClass, selector }) => { + const node = mediaNode({ linkedInputName: selector, nodeClass }) + + expect(shouldHideLinkedCoreMediaInputActions(node)).toBe(false) + expect(shouldHideLinkedCoreLoadAudioPlayer(node)).toBe(false) + } + ) + + it('ignores an unrelated linked widget', () => { + const node = mediaNode({ linkedInputName: 'mask', nodeClass: 'LoadImage' }) + + expect(shouldHideLinkedCoreMediaInputActions(node)).toBe(false) + expect(shouldHideLinkedCoreLoadAudioPlayer(node)).toBe(false) + }) + + it('reads the live selector input and restores availability on disconnect', () => { + const node = mediaNode({ + linkedInputName: 'audio', + nodeClass: 'LoadAudio' + }) + + expect(shouldHideLinkedCoreLoadAudioPlayer(node)).toBe(true) + expect(node.isInputConnected).toHaveBeenCalledWith(0) + + vi.mocked(node.isInputConnected).mockReturnValue(false) + + expect(shouldHideLinkedCoreLoadAudioPlayer(node)).toBe(false) + }) +}) + +describe(shouldHideLinkedCoreMediaInputActions, () => { + it.for([ + { nodeClass: 'LoadImage', selector: 'image' }, + { nodeClass: 'LoadImageMask', selector: 'image' }, + { nodeClass: 'LoadImageOutput', selector: 'image' }, + { nodeClass: 'LoadVideo', selector: 'file' } + ] as const)( + 'hides input actions for a linked core $nodeClass selector', + ({ nodeClass, selector }) => { + const node = mediaNode({ linkedInputName: selector, nodeClass }) + + expect(shouldHideLinkedCoreMediaInputActions(node)).toBe(true) + } + ) + + it('does not hide image actions for a linked LoadAudio selector', () => { + const node = mediaNode({ + linkedInputName: 'audio', + nodeClass: 'LoadAudio' + }) + + expect(shouldHideLinkedCoreMediaInputActions(node)).toBe(false) + }) +}) + +describe(shouldHideLinkedCoreMediaInputPreview, () => { + it.for([ + { nodeClass: 'LoadImage', selector: 'image' }, + { nodeClass: 'LoadImageMask', selector: 'image' }, + { nodeClass: 'LoadImageOutput', selector: 'image' }, + { nodeClass: 'LoadVideo', selector: 'file' } + ] as const)( + 'hides only an input preview on core $nodeClass', + ({ nodeClass, selector }) => { + const node = mediaNode({ linkedInputName: selector, nodeClass }) + + expect( + shouldHideLinkedCoreMediaInputPreview(node, { + images: [{ type: 'input' }] + }) + ).toBe(true) + expect( + shouldHideLinkedCoreMediaInputPreview(node, { + images: [{ type: 'output' }] + }) + ).toBe(false) + expect( + shouldHideLinkedCoreMediaInputPreview(node, { + images: [{ type: 'input' }, { type: 'output' }] + }) + ).toBe(false) + expect(shouldHideLinkedCoreMediaInputPreview(node, { images: [] })).toBe( + false + ) + } + ) + + it('keeps the preview for a linked non-selector widget', () => { + const node = mediaNode({ nodeClass: 'LoadImage' }) + + expect( + shouldHideLinkedCoreMediaInputPreview( + node, + { images: [{ type: 'input' }] }, + [linkedWidget('seed')] + ) + ).toBe(false) + }) +}) diff --git a/src/renderer/extensions/vueNodes/utils/linkedCoreMediaUtils.ts b/src/renderer/extensions/vueNodes/utils/linkedCoreMediaUtils.ts new file mode 100644 index 00000000000..2c28619c933 --- /dev/null +++ b/src/renderer/extensions/vueNodes/utils/linkedCoreMediaUtils.ts @@ -0,0 +1,101 @@ +import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { NodeExecutionOutput } from '@/schemas/apiSchema' +import { isInputPreviewOutput } from '@/utils/nodeOutputUtil' + +type MediaLoaderSelectorWidget = Pick + +const LINKED_CORE_MEDIA_LOADERS = { + LoadAudio: { selectorName: 'audio', showsInputPreview: false }, + LoadImage: { selectorName: 'image', showsInputPreview: true }, + LoadImageMask: { selectorName: 'image', showsInputPreview: true }, + LoadImageOutput: { selectorName: 'image', showsInputPreview: true }, + LoadVideo: { selectorName: 'file', showsInputPreview: true } +} as const + +type LinkedCoreMediaLoaderClass = keyof typeof LINKED_CORE_MEDIA_LOADERS + +function isLinkedCoreMediaLoaderClass( + value: string +): value is LinkedCoreMediaLoaderClass { + return Object.hasOwn(LINKED_CORE_MEDIA_LOADERS, value) +} + +function getCoreMediaLoaderClass( + node: LGraphNode +): LinkedCoreMediaLoaderClass | undefined { + const nodeData = node.constructor.nodeData + const nodeClass = node.constructor.comfyClass + if ( + !nodeData || + !('isCoreNode' in nodeData) || + nodeData.isCoreNode !== true || + !isLinkedCoreMediaLoaderClass(nodeClass) + ) + return undefined + + return nodeClass +} + +function isMediaLoaderSelectorLinked( + node: LGraphNode, + nodeClass: LinkedCoreMediaLoaderClass, + widgets?: readonly MediaLoaderSelectorWidget[] +): boolean { + const { selectorName } = LINKED_CORE_MEDIA_LOADERS[nodeClass] + if (widgets) { + return widgets.some( + (widget) => + widget.name === selectorName && widget.slotMetadata?.linked === true + ) + } + + const selectorWidget = node.widgets?.find( + (widget) => widget.name === selectorName + ) + const selectorSlot = node.getSlotFromWidget(selectorWidget) + if (!selectorSlot) return false + + const selectorSlotIndex = node.inputs.indexOf(selectorSlot) + return selectorSlotIndex >= 0 && node.isInputConnected(selectorSlotIndex) +} + +function getLinkedCoreMediaLoaderClass( + node: LGraphNode, + widgets?: readonly MediaLoaderSelectorWidget[] +): LinkedCoreMediaLoaderClass | undefined { + const nodeClass = getCoreMediaLoaderClass(node) + if (!nodeClass || !isMediaLoaderSelectorLinked(node, nodeClass, widgets)) + return undefined + + return nodeClass +} + +export function shouldHideLinkedCoreMediaInputActions( + node: LGraphNode, + widgets?: readonly MediaLoaderSelectorWidget[] +): boolean { + const nodeClass = getLinkedCoreMediaLoaderClass(node, widgets) + return ( + nodeClass !== undefined && + LINKED_CORE_MEDIA_LOADERS[nodeClass].showsInputPreview + ) +} + +export function shouldHideLinkedCoreMediaInputPreview( + node: LGraphNode, + output: Pick | undefined, + widgets?: readonly MediaLoaderSelectorWidget[] +): boolean { + return ( + shouldHideLinkedCoreMediaInputActions(node, widgets) && + isInputPreviewOutput(output) + ) +} + +export function shouldHideLinkedCoreLoadAudioPlayer( + node: LGraphNode, + widgets?: readonly MediaLoaderSelectorWidget[] +): boolean { + return getLinkedCoreMediaLoaderClass(node, widgets) === 'LoadAudio' +} diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index b4beadc87d1..ea232c491f2 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -64,6 +64,7 @@ import { useWidgetStore } from '@/stores/widgetStore' import { parseNodeId } from '@/types/nodeId' import type { SerializedNodeId } from '@/types/nodeId' import { isBlueprintType } from '@/utils/blueprintUtils' +import { markCoreMediaMenuCallback } from '@/utils/coreMediaMenuActionUtils' import type { WidgetId } from '@/types/widgetId' import { normalizeI18nKey } from '@/utils/formatUtil' import { @@ -620,7 +621,7 @@ export const useLitegraphService = () => { return [ { content: 'Copy Image', - callback: async () => { + callback: markCoreMediaMenuCallback(async () => { const url = new URL(img.src) url.searchParams.delete('preview') @@ -654,7 +655,7 @@ export const useLitegraphService = () => { }) ) } - } + }, 'preview') } ] } @@ -674,21 +675,21 @@ export const useLitegraphService = () => { options.unshift( { content: 'Open Image', - callback: () => { + callback: markCoreMediaMenuCallback(() => { const url = new URL(img.src) url.searchParams.delete('preview') void openFileInNewTab(url.toString()) - } + }, 'preview') }, ...getCopyImageOption(img), { content: 'Save Image', - callback: () => { + callback: markCoreMediaMenuCallback(() => { const url = new URL(img.src) url.searchParams.delete('preview') const filename = new URLSearchParams(url.search).get('filename') downloadFile(url.toString(), filename ?? undefined) - } + }, 'preview') } ) } @@ -714,18 +715,18 @@ export const useLitegraphService = () => { if (ComfyApp.clipspace != null) { options.push({ content: 'Paste (Clipspace)', - callback: () => { + callback: markCoreMediaMenuCallback(() => { ComfyApp.pasteFromClipspace(this) - } + }, 'input') }) } if (isImageNode(this)) { options.push({ content: 'Open in MaskEditor | Image Canvas', - callback: () => { + callback: markCoreMediaMenuCallback(() => { useMaskEditor().openMaskEditor(this) - } + }, 'preview') }) } } diff --git a/src/stores/nodeOutputStore.ts b/src/stores/nodeOutputStore.ts index 381ecd93883..c1f126d71ca 100644 --- a/src/stores/nodeOutputStore.ts +++ b/src/stores/nodeOutputStore.ts @@ -24,6 +24,7 @@ import { isVideoNode, resolveNode } from '@/utils/litegraphUtil' +import { isInputPreviewOutput } from '@/utils/nodeOutputUtil' import { releaseSharedObjectUrl, retainSharedObjectUrl @@ -154,17 +155,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { return buildImageUrls(node, getNodeOutputByExecutionId(executionId)) } - function isInputPreviewOutput( - output: ExecutedWsMessage['output'] | ResultItem | undefined - ): boolean { - const images = (output as ExecutedWsMessage['output'] | undefined)?.images - return ( - Array.isArray(images) && - images.length > 0 && - images.every((i) => i?.type === 'input') - ) - } - function setOutputsByLocatorId( nodeLocatorId: NodeLocatorId, outputs: ExecutedWsMessage['output'] | ResultItem, diff --git a/src/utils/coreMediaMenuActionUtils.test.ts b/src/utils/coreMediaMenuActionUtils.test.ts new file mode 100644 index 00000000000..b9bd1c07f6d --- /dev/null +++ b/src/utils/coreMediaMenuActionUtils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + filterUnavailableCoreMediaMenuActions, + markCoreMediaMenuCallback +} from './coreMediaMenuActionUtils' + +describe('core media menu action provenance', () => { + it('removes the unavailable core action but preserves an extension action with the same label', () => { + const coreCallback = vi.fn() + const extensionCallback = vi.fn() + const coreAction = { + content: 'Open Image', + callback: markCoreMediaMenuCallback(coreCallback, 'preview') + } + const extensionAction = { + content: 'Open Image', + callback: extensionCallback + } + + expect( + filterUnavailableCoreMediaMenuActions( + [coreAction, extensionAction], + new Set(['preview']) + ) + ).toEqual([extensionAction]) + }) + + it('filters input and preview actions independently', () => { + const inputAction = { + content: 'Paste Image', + callback: markCoreMediaMenuCallback(vi.fn(), 'input') + } + const previewAction = { + content: 'Save Image', + callback: markCoreMediaMenuCallback(vi.fn(), 'preview') + } + + expect( + filterUnavailableCoreMediaMenuActions( + [inputAction, previewAction], + new Set(['input']) + ) + ).toEqual([previewAction]) + }) +}) diff --git a/src/utils/coreMediaMenuActionUtils.ts b/src/utils/coreMediaMenuActionUtils.ts new file mode 100644 index 00000000000..378c106a3c5 --- /dev/null +++ b/src/utils/coreMediaMenuActionUtils.ts @@ -0,0 +1,27 @@ +export type CoreMediaMenuActionKind = 'input' | 'preview' + +const coreMediaMenuActionKinds = new WeakMap() + +export function markCoreMediaMenuCallback( + callback: T, + kind: CoreMediaMenuActionKind +): T { + coreMediaMenuActionKinds.set(callback, kind) + return callback +} + +export function filterUnavailableCoreMediaMenuActions( + options: readonly T[], + unavailableKinds: ReadonlySet +): T[] { + return options.filter((option) => { + if (!option || typeof option !== 'object' || !('callback' in option)) + return true + + const { callback } = option + if (typeof callback !== 'function') return true + + const kind = coreMediaMenuActionKinds.get(callback) + return !kind || !unavailableKinds.has(kind) + }) +} diff --git a/src/utils/nodeOutputUtil.test.ts b/src/utils/nodeOutputUtil.test.ts new file mode 100644 index 00000000000..74ad7c76b37 --- /dev/null +++ b/src/utils/nodeOutputUtil.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import type { NodeExecutionOutput } from '@/schemas/apiSchema' + +import { isInputPreviewOutput } from './nodeOutputUtil' + +describe(isInputPreviewOutput, () => { + const cases: { + expected: boolean + output: Pick | undefined + }[] = [ + { + expected: true, + output: { images: [{ type: 'input' }, { type: 'input' }] } + }, + { expected: false, output: { images: [{ type: 'output' }] } }, + { + expected: false, + output: { images: [{ type: 'input' }, { type: 'output' }] } + }, + { expected: false, output: { images: [] } }, + { expected: false, output: undefined } + ] + + it.for(cases)('classifies input preview provenance $expected', (testCase) => { + expect(isInputPreviewOutput(testCase.output)).toBe(testCase.expected) + }) + + it('rejects a non-array images payload', () => { + const malformedOutput = { images: { length: 1 } } as unknown as Pick< + NodeExecutionOutput, + 'images' + > + + expect(isInputPreviewOutput(malformedOutput)).toBe(false) + }) +}) diff --git a/src/utils/nodeOutputUtil.ts b/src/utils/nodeOutputUtil.ts new file mode 100644 index 00000000000..144073868b2 --- /dev/null +++ b/src/utils/nodeOutputUtil.ts @@ -0,0 +1,12 @@ +import type { NodeExecutionOutput } from '@/schemas/apiSchema' + +export function isInputPreviewOutput( + output: Pick | undefined +): boolean { + const images = output?.images + return ( + Array.isArray(images) && + images.length > 0 && + images.every((image) => image?.type === 'input') + ) +}