diff --git a/browser_tests/assets/nodes/glsl_shader_subgraph_with_loadimage.json b/browser_tests/assets/nodes/glsl_shader_subgraph_with_loadimage.json new file mode 100644 index 0000000000..b875f98405 --- /dev/null +++ b/browser_tests/assets/nodes/glsl_shader_subgraph_with_loadimage.json @@ -0,0 +1,181 @@ +{ + "id": "ee111111-2222-4333-8444-000000000003", + "revision": 0, + "last_node_id": 2, + "last_link_id": 1, + "nodes": [ + { + "id": 2, + "type": "LoadImage", + "pos": [50, 200], + "size": [315, 314], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { "name": "IMAGE", "type": "IMAGE", "links": [1] }, + { "name": "MASK", "type": "MASK", "links": null } + ], + "properties": { "Node name for S&R": "LoadImage" }, + "widgets_values": ["example.png", "image"] + }, + { + "id": 1, + "type": "aa999999-8888-4777-a666-555555555557", + "pos": [500, 200], + "size": [400, 200], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "image0", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }], + "title": "GLSL Subgraph With Image", + "properties": {}, + "widgets_values": [] + } + ], + "links": [[1, 2, 0, 1, 0, "IMAGE"]], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "aa999999-8888-4777-a666-555555555557", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 1, + "lastLinkId": 2, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "GLSL Subgraph With Image", + "inputNode": { + "id": -10, + "bounding": [50, 200, 120, 60] + }, + "outputNode": { + "id": -20, + "bounding": [900, 200, 120, 60] + }, + "inputs": [ + { + "id": "cc777777-6666-4555-a444-333333333333", + "name": "image0", + "type": "IMAGE", + "linkIds": [2], + "pos": { "0": 180, "1": 220 } + } + ], + "outputs": [ + { + "id": "bb888888-7777-4666-a555-444444444446", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [1], + "pos": { "0": 920, "1": 220 } + } + ], + "widgets": [], + "nodes": [ + { + "id": 1, + "type": "GLSLShader", + "pos": [400, 180], + "size": [460, 320], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "label": "image0", + "localized_name": "images.image0", + "name": "images.image0", + "shape": 7, + "type": "IMAGE", + "link": 2 + }, + { + "localized_name": "fragment_shader", + "name": "fragment_shader", + "type": "STRING", + "widget": { "name": "fragment_shader" }, + "link": null + }, + { + "localized_name": "size_mode", + "name": "size_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { "name": "size_mode" }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE0", + "name": "IMAGE0", + "type": "IMAGE", + "links": [1] + }, + { + "localized_name": "IMAGE1", + "name": "IMAGE1", + "type": "IMAGE", + "links": null + }, + { + "localized_name": "IMAGE2", + "name": "IMAGE2", + "type": "IMAGE", + "links": null + }, + { + "localized_name": "IMAGE3", + "name": "IMAGE3", + "type": "IMAGE", + "links": null + } + ], + "properties": { "Node name for S&R": "GLSLShader" }, + "widgets_values": [ + "#version 300 es\nprecision highp float;\nuniform sampler2D u_image0;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = texture(u_image0, v_texCoord);\n}\n", + "from_input" + ] + } + ], + "groups": [], + "links": [ + { + "id": 1, + "origin_id": 1, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 2, + "origin_id": -10, + "origin_slot": 0, + "target_id": 1, + "target_slot": 0, + "type": "IMAGE" + } + ], + "extra": {} + } + ] + }, + "config": {}, + "extra": { + "ds": { "offset": [0, 0], "scale": 1 } + }, + "version": 0.4 +} diff --git a/browser_tests/tests/vueNodes/glslPreview.spec.ts b/browser_tests/tests/vueNodes/glslPreview.spec.ts index dee3f90286..e2652aaa49 100644 --- a/browser_tests/tests/vueNodes/glslPreview.spec.ts +++ b/browser_tests/tests/vueNodes/glslPreview.spec.ts @@ -70,27 +70,27 @@ class GLSLShaderNode { ) { this.node = comfyPage.vueNodes.getNodeLocator(nodeId) this.previewImage = this.node.locator('img[src^="blob:"]') - this.shaderTextbox = this.node.getByRole('textbox', { - name: 'fragment_shader' - }) - this.widthInput = this.node - .getByLabel('size_mode.width', { exact: true }) + this.shaderTextbox = comfyPage.vueNodes.getWidgetByName( + title, + 'fragment_shader' + ) + this.widthInput = comfyPage.vueNodes + .getWidgetByName(title, 'size_mode.width') .locator('input') - this.heightInput = this.node - .getByLabel('size_mode.height', { exact: true }) + this.heightInput = comfyPage.vueNodes + .getWidgetByName(title, 'size_mode.height') .locator('input') } - /** - * Fire `execution_start` + `executed` with an image output for this node, - * which satisfies the `hasExecutionOutput` gate in `useGLSLPreview`. - */ - async simulateExecutionOutput(ws: WebSocketRoute) { + async simulateExecutionOutput( + ws: WebSocketRoute, + executionNodeId: string = this.nodeId + ) { const exec = new ExecutionHelper(this.comfyPage, ws) const jobId = await exec.run() await this.comfyPage.nextFrame() exec.executionStart(jobId) - exec.executed(jobId, this.nodeId, { + exec.executed(jobId, executionNodeId, { images: [{ filename: 'glsl_test.png', subfolder: '', type: 'output' }] }) exec.executionSuccess(jobId) @@ -587,4 +587,153 @@ test.describe('GLSL Shader Preview', { tag: ['@vue-nodes', '@node'] }, () => { await subgraph.expectEveryPixelToBe([255, 0, 0, 255]) }) }) + + test.describe('renderer paths', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_standalone') + await comfyPage.vueNodes.waitForNodes(1) + }) + + test('scales custom resolution down to the max preview dimension', async ({ + comfyPage, + getWebSocket + }) => { + const ws = await getWebSocket() + const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE) + + await glsl.selectSizeMode('custom') + await expect(glsl.widthInput).toBeVisible() + await glsl.widthInput.fill('2048') + await glsl.widthInput.blur() + await glsl.heightInput.fill('1024') + await glsl.heightInput.blur() + + await glsl.simulateExecutionOutput(ws) + await glsl.waitForBlobSrc() + + // MAX_PREVIEW_DIMENSION is 1024 → clampResolution scales 2048x1024 to 1024x512. + await expect + .poll(() => glsl.getPreviewNaturalSize()) + .toEqual({ width: 1024, height: 512 }) + }) + + test('runs a multi-pass shader via #pragma passes', async ({ + comfyPage, + getWebSocket + }) => { + const ws = await getWebSocket() + const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE) + + // Pass 0 writes red to the ping-pong FBO; pass 1 swizzles it to green. + // If pass 0 didn't run, u_image0 is the black fallback and output is black. + const MULTIPASS_SHADER = [ + '#version 300 es', + '#pragma passes 2', + 'precision highp float;', + 'uniform int u_pass;', + 'uniform sampler2D u_image0;', + 'in vec2 v_texCoord;', + 'layout(location = 0) out vec4 fragColor0;', + 'void main() {', + ' if (u_pass == 0) {', + ' fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);', + ' } else {', + ' vec4 prev = texture(u_image0, v_texCoord);', + ' fragColor0 = vec4(prev.g, prev.r, 0.0, 1.0);', + ' }', + '}' + ].join('\n') + + await glsl.shaderTextbox.fill(MULTIPASS_SHADER) + await glsl.simulateExecutionOutput(ws) + await glsl.waitForBlobSrc() + // RGBA16F → WebP round-trip drifts a couple of levels on saturated channels. + await glsl.expectEveryPixelToBe([0, 255, 0, 255], 2) + }) + + test('revokes the preview blob URL when the GLSL node is removed', async ({ + comfyPage, + getWebSocket + }) => { + const ws = await getWebSocket() + const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE) + + await glsl.simulateExecutionOutput(ws) + const blobUrl = await glsl.waitForBlobSrc() + + // Sanity check: the blob URL resolves before the node is deleted. + await expect( + comfyPage.page.evaluate((url) => fetch(url), blobUrl) + ).resolves.toBeTruthy() + + await comfyPage.vueNodes.deleteNode(GLSL_NODE_ID) + await expect(glsl.node).toHaveCount(0) + + // Delete tears down the inner scope → URL.revokeObjectURL → fetch rejects. + await expect + .poll(() => + comfyPage.page.evaluate(async (url) => { + try { + await fetch(url) + return 'resolved' + } catch { + return 'rejected' + } + }, blobUrl) + ) + .toBe('rejected') + }) + }) + + test.describe('inner GLSL sampling image through subgraph boundary', () => { + // Outer ids come from the workflow JSON; inner ids are reassigned on load. + const OUTER_LOAD_IMAGE_NODE_ID = '2' + const OUTER_SUBGRAPH_NODE_ID = '1' + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow( + 'nodes/glsl_shader_subgraph_with_loadimage' + ) + await comfyPage.vueNodes.waitForNodes(2) + }) + + test('inner GLSL node reads upstream image via subgraph boundary', async ({ + comfyPage, + getWebSocket + }) => { + const ws = await getWebSocket() + + await dropImageOntoLoadImage( + comfyPage, + OUTER_LOAD_IMAGE_NODE_ID, + 'image64x64.webp' + ) + + // Enter the subgraph so the inner GLSL node mounts its LGraphNode.vue + await comfyPage.vueNodes.enterSubgraph(OUTER_SUBGRAPH_NODE_ID) + await comfyPage.vueNodes.waitForNodes(1) + + const innerNode = comfyPage.vueNodes + .getNodeByTitle(GLSL_NODE_TITLE) + .first() + await innerNode.waitFor({ state: 'visible' }) + const innerGlslId = await innerNode.getAttribute('data-node-id') + if (!innerGlslId) { + throw new Error('inner GLSLShader node is missing data-node-id') + } + + const glsl = new GLSLShaderNode(comfyPage, innerGlslId, GLSL_NODE_TITLE) + await glsl.simulateExecutionOutput( + ws, + `${OUTER_SUBGRAPH_NODE_ID}:${innerGlslId}` + ) + + await expect(glsl.previewImage).toBeVisible() + await glsl.waitForBlobSrc() + + await expect + .poll(() => glsl.getPreviewNaturalSize()) + .toEqual({ width: 64, height: 64 }) + }) + }) })