import { start, createDefaultWorkflow, getNodeDef, checkBeforeAndAfterReload } from '../utils' import { EzNode } from '../utils/ezgraph' import lg from '../utils/litegraph' describe('group node', () => { beforeEach(() => { lg.setup(global) }) afterEach(() => { lg.teardown(global) }) /** * * @param {*} app * @param {*} graph * @param {*} name * @param {*} nodes * @returns { Promise> } */ async function convertToGroup(app, graph, name, nodes) { // Select the nodes we are converting for (const n of nodes) { n.select(true) } expect( Object.keys(app.canvas.selected_nodes).sort((a, b) => +a - +b) ).toEqual(nodes.map((n) => n.id + '').sort((a, b) => +a - +b)) global.prompt = jest.fn().mockImplementation(() => name) const groupNode = await nodes[0].menu['Convert to Group Node'].call(false) // Check group name was requested expect(window.prompt).toHaveBeenCalled() // Ensure old nodes are removed for (const n of nodes) { expect(n.isRemoved).toBeTruthy() } expect(groupNode.type).toEqual('workflow/' + name) return graph.find(groupNode) } /** * @param { Record | number[] } idMap * @param { Record> } valueMap */ function getOutput(idMap = {}, valueMap = {}) { if (idMap instanceof Array) { idMap = idMap.reduce((p, n) => { p[n] = n + '' return p }, {}) } const expected = { 1: { inputs: { ckpt_name: 'model1.safetensors', ...valueMap?.[1] }, class_type: 'CheckpointLoaderSimple' }, 2: { inputs: { text: 'positive', clip: ['1', 1], ...valueMap?.[2] }, class_type: 'CLIPTextEncode' }, 3: { inputs: { text: 'negative', clip: ['1', 1], ...valueMap?.[3] }, class_type: 'CLIPTextEncode' }, 4: { inputs: { width: 512, height: 512, batch_size: 1, ...valueMap?.[4] }, class_type: 'EmptyLatentImage' }, 5: { inputs: { seed: 0, steps: 20, cfg: 8, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['1', 0], positive: ['2', 0], negative: ['3', 0], latent_image: ['4', 0], ...valueMap?.[5] }, class_type: 'KSampler' }, 6: { inputs: { samples: ['5', 0], vae: ['1', 2], ...valueMap?.[6] }, class_type: 'VAEDecode' }, 7: { inputs: { filename_prefix: 'ComfyUI', images: ['6', 0], ...valueMap?.[7] }, class_type: 'SaveImage' } } // Map old IDs to new at the top level const mapped = {} for (const oldId in idMap) { mapped[idMap[oldId]] = expected[oldId] delete expected[oldId] } Object.assign(mapped, expected) // Map old IDs to new inside links for (const k in mapped) { for (const input in mapped[k].inputs) { const v = mapped[k].inputs[input] if (v instanceof Array) { if (v[0] in idMap) { v[0] = idMap[v[0]] + '' } } } } return mapped } test('can be created from selected nodes', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) const group = await convertToGroup(app, graph, 'test', [ nodes.pos, nodes.neg, nodes.empty ]) // Ensure links are now to the group node expect(group.inputs).toHaveLength(2) expect(group.outputs).toHaveLength(3) expect(group.inputs.map((i) => i.input.name)).toEqual([ 'clip', 'CLIPTextEncode clip' ]) expect(group.outputs.map((i) => i.output.name)).toEqual([ 'LATENT', 'CONDITIONING', 'CLIPTextEncode CONDITIONING' ]) // ckpt clip to both clip inputs on the group expect( nodes.ckpt.outputs.CLIP.connections.map((t) => [ t.targetNode.id, t.targetInput.index ]) ).toEqual([ [group.id, 0], [group.id, 1] ]) // group conditioning to sampler expect( group.outputs['CONDITIONING'].connections.map((t) => [ t.targetNode.id, t.targetInput.index ]) ).toEqual([[nodes.sampler.id, 1]]) // group conditioning 2 to sampler expect( group.outputs['CLIPTextEncode CONDITIONING'].connections.map((t) => [ t.targetNode.id, t.targetInput.index ]) ).toEqual([[nodes.sampler.id, 2]]) // group latent to sampler expect( group.outputs['LATENT'].connections.map((t) => [ t.targetNode.id, t.targetInput.index ]) ).toEqual([[nodes.sampler.id, 3]]) }) test('maintains all output links on conversion', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) const save2 = ez.SaveImage(...nodes.decode.outputs) const save3 = ez.SaveImage(...nodes.decode.outputs) // Ensure an output with multiple links maintains them on convert to group const group = await convertToGroup(app, graph, 'test', [ nodes.sampler, nodes.decode ]) expect(group.outputs[0].connections.length).toBe(3) expect(group.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id) expect(group.outputs[0].connections[1].targetNode.id).toBe(save2.id) expect(group.outputs[0].connections[2].targetNode.id).toBe(save3.id) // and they're still linked when converting back to nodes const newNodes = group.menu['Convert to nodes'].call() const decode = graph.find(newNodes.find((n) => n.type === 'VAEDecode')) expect(decode.outputs[0].connections.length).toBe(3) expect(decode.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id) expect(decode.outputs[0].connections[1].targetNode.id).toBe(save2.id) expect(decode.outputs[0].connections[2].targetNode.id).toBe(save3.id) }) test('can be be converted back to nodes', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) const toConvert = [nodes.pos, nodes.neg, nodes.empty, nodes.sampler] const group = await convertToGroup(app, graph, 'test', toConvert) // Edit some values to ensure they are set back onto the converted nodes expect(group.widgets['text'].value).toBe('positive') group.widgets['text'].value = 'pos' expect(group.widgets['CLIPTextEncode text'].value).toBe('negative') group.widgets['CLIPTextEncode text'].value = 'neg' expect(group.widgets['width'].value).toBe(512) group.widgets['width'].value = 1024 expect(group.widgets['sampler_name'].value).toBe('euler') group.widgets['sampler_name'].value = 'ddim' expect(group.widgets['control_after_generate'].value).toBe('randomize') group.widgets['control_after_generate'].value = 'fixed' /** @type { Array } */ group.menu['Convert to nodes'].call() // ensure widget values are set const pos = graph.find(nodes.pos.id) expect(pos.node.type).toBe('CLIPTextEncode') expect(pos.widgets['text'].value).toBe('pos') const neg = graph.find(nodes.neg.id) expect(neg.node.type).toBe('CLIPTextEncode') expect(neg.widgets['text'].value).toBe('neg') const empty = graph.find(nodes.empty.id) expect(empty.node.type).toBe('EmptyLatentImage') expect(empty.widgets['width'].value).toBe(1024) const sampler = graph.find(nodes.sampler.id) expect(sampler.node.type).toBe('KSampler') expect(sampler.widgets['sampler_name'].value).toBe('ddim') expect(sampler.widgets['control_after_generate'].value).toBe('fixed') // validate links expect( nodes.ckpt.outputs.CLIP.connections.map((t) => [ t.targetNode.id, t.targetInput.index ]) ).toEqual([ [pos.id, 0], [neg.id, 0] ]) expect( pos.outputs['CONDITIONING'].connections.map((t) => [ t.targetNode.id, t.targetInput.index ]) ).toEqual([[nodes.sampler.id, 1]]) expect( neg.outputs['CONDITIONING'].connections.map((t) => [ t.targetNode.id, t.targetInput.index ]) ).toEqual([[nodes.sampler.id, 2]]) expect( empty.outputs['LATENT'].connections.map((t) => [ t.targetNode.id, t.targetInput.index ]) ).toEqual([[nodes.sampler.id, 3]]) }) test('it can embed reroutes as inputs', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) // Add and connect a reroute to the clip text encodes const reroute = ez.Reroute() nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]) reroute.outputs[0].connectTo(nodes.pos.inputs[0]) reroute.outputs[0].connectTo(nodes.neg.inputs[0]) // Convert to group and ensure we only have 1 input of the correct type const group = await convertToGroup(app, graph, 'test', [ nodes.pos, nodes.neg, nodes.empty, reroute ]) expect(group.inputs).toHaveLength(1) expect(group.inputs[0].input.type).toEqual('CLIP') expect((await graph.toPrompt()).output).toEqual(getOutput()) }) test('it can embed reroutes as outputs', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) // Add a reroute with no output so we output IMAGE even though its used internally const reroute = ez.Reroute() nodes.decode.outputs.IMAGE.connectTo(reroute.inputs[0]) // Convert to group and ensure there is an IMAGE output const group = await convertToGroup(app, graph, 'test', [ nodes.decode, nodes.save, reroute ]) expect(group.outputs).toHaveLength(1) expect(group.outputs[0].output.type).toEqual('IMAGE') expect((await graph.toPrompt()).output).toEqual( getOutput([nodes.decode.id, nodes.save.id]) ) }) test('it can embed reroutes as pipes', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) // Use reroutes as a pipe const rerouteModel = ez.Reroute() const rerouteClip = ez.Reroute() const rerouteVae = ez.Reroute() nodes.ckpt.outputs.MODEL.connectTo(rerouteModel.inputs[0]) nodes.ckpt.outputs.CLIP.connectTo(rerouteClip.inputs[0]) nodes.ckpt.outputs.VAE.connectTo(rerouteVae.inputs[0]) const group = await convertToGroup(app, graph, 'test', [ rerouteModel, rerouteClip, rerouteVae ]) expect(group.outputs).toHaveLength(3) expect(group.outputs.map((o) => o.output.type)).toEqual([ 'MODEL', 'CLIP', 'VAE' ]) expect(group.outputs).toHaveLength(3) expect(group.outputs.map((o) => o.output.type)).toEqual([ 'MODEL', 'CLIP', 'VAE' ]) group.outputs[0].connectTo(nodes.sampler.inputs.model) group.outputs[1].connectTo(nodes.pos.inputs.clip) group.outputs[1].connectTo(nodes.neg.inputs.clip) }) test('can handle reroutes used internally', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) let reroutes: EzNode[] = [] let prevNode = nodes.ckpt for (let i = 0; i < 5; i++) { const reroute = ez.Reroute() prevNode.outputs[0].connectTo(reroute.inputs[0]) prevNode = reroute reroutes.push(reroute) } prevNode.outputs[0].connectTo(nodes.sampler.inputs.model) const group = await convertToGroup(app, graph, 'test', [ ...reroutes, ...Object.values(nodes) ]) expect((await graph.toPrompt()).output).toEqual(getOutput()) group.menu['Convert to nodes'].call() expect((await graph.toPrompt()).output).toEqual(getOutput()) }) test('creates with widget values from inner nodes', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) nodes.ckpt.widgets.ckpt_name.value = 'model2.ckpt' nodes.pos.widgets.text.value = 'hello' nodes.neg.widgets.text.value = 'world' nodes.empty.widgets.width.value = 256 nodes.empty.widgets.height.value = 1024 nodes.sampler.widgets.seed.value = 1 nodes.sampler.widgets.control_after_generate.value = 'increment' nodes.sampler.widgets.steps.value = 8 nodes.sampler.widgets.cfg.value = 4.5 nodes.sampler.widgets.sampler_name.value = 'uni_pc' nodes.sampler.widgets.scheduler.value = 'karras' nodes.sampler.widgets.denoise.value = 0.9 const group = await convertToGroup(app, graph, 'test', [ nodes.ckpt, nodes.pos, nodes.neg, nodes.empty, nodes.sampler ]) expect(group.widgets['ckpt_name'].value).toEqual('model2.ckpt') expect(group.widgets['text'].value).toEqual('hello') expect(group.widgets['CLIPTextEncode text'].value).toEqual('world') expect(group.widgets['width'].value).toEqual(256) expect(group.widgets['height'].value).toEqual(1024) expect(group.widgets['seed'].value).toEqual(1) expect(group.widgets['control_after_generate'].value).toEqual('increment') expect(group.widgets['steps'].value).toEqual(8) expect(group.widgets['cfg'].value).toEqual(4.5) expect(group.widgets['sampler_name'].value).toEqual('uni_pc') expect(group.widgets['scheduler'].value).toEqual('karras') expect(group.widgets['denoise'].value).toEqual(0.9) expect((await graph.toPrompt()).output).toEqual( getOutput( [ nodes.ckpt.id, nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id ], { [nodes.ckpt.id]: { ckpt_name: 'model2.ckpt' }, [nodes.pos.id]: { text: 'hello' }, [nodes.neg.id]: { text: 'world' }, [nodes.empty.id]: { width: 256, height: 1024 }, [nodes.sampler.id]: { seed: 1, steps: 8, cfg: 4.5, sampler_name: 'uni_pc', scheduler: 'karras', denoise: 0.9 } } ) ) }) test('group inputs can be reroutes', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) const group = await convertToGroup(app, graph, 'test', [ nodes.pos, nodes.neg ]) const reroute = ez.Reroute() nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]) reroute.outputs[0].connectTo(group.inputs[0]) reroute.outputs[0].connectTo(group.inputs[1]) expect((await graph.toPrompt()).output).toEqual( getOutput([nodes.pos.id, nodes.neg.id]) ) }) test('group outputs can be reroutes', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) const group = await convertToGroup(app, graph, 'test', [ nodes.pos, nodes.neg ]) const reroute1 = ez.Reroute() const reroute2 = ez.Reroute() group.outputs[0].connectTo(reroute1.inputs[0]) group.outputs[1].connectTo(reroute2.inputs[0]) reroute1.outputs[0].connectTo(nodes.sampler.inputs.positive) reroute2.outputs[0].connectTo(nodes.sampler.inputs.negative) expect((await graph.toPrompt()).output).toEqual( getOutput([nodes.pos.id, nodes.neg.id]) ) }) test('groups can connect to each other', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) const group1 = await convertToGroup(app, graph, 'test', [ nodes.pos, nodes.neg ]) const group2 = await convertToGroup(app, graph, 'test2', [ nodes.empty, nodes.sampler ]) group1.outputs[0].connectTo(group2.inputs['positive']) group1.outputs[1].connectTo(group2.inputs['negative']) expect((await graph.toPrompt()).output).toEqual( getOutput([nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id]) ) }) test('groups can connect to each other via internal reroutes', async () => { const { ez, graph, app } = await start() const latent = ez.EmptyLatentImage() const vae = ez.VAELoader() const latentReroute = ez.Reroute() const vaeReroute = ez.Reroute() latent.outputs[0].connectTo(latentReroute.inputs[0]) vae.outputs[0].connectTo(vaeReroute.inputs[0]) const group1 = await convertToGroup(app, graph, 'test', [ latentReroute, vaeReroute ]) group1.menu.Clone.call() expect(app.graph._nodes).toHaveLength(4) const group2 = graph.find(app.graph._nodes[3]) expect(group2.node.type).toEqual('workflow/test') expect(group2.id).not.toEqual(group1.id) group1.outputs.VAE.connectTo(group2.inputs.VAE) group1.outputs.LATENT.connectTo(group2.inputs.LATENT) const decode = ez.VAEDecode(group2.outputs.LATENT, group2.outputs.VAE) const preview = ez.PreviewImage(decode.outputs[0]) const output = { [latent.id]: { inputs: { width: 512, height: 512, batch_size: 1 }, class_type: 'EmptyLatentImage' }, [vae.id]: { inputs: { vae_name: 'vae1.safetensors' }, class_type: 'VAELoader' }, [decode.id]: { inputs: { samples: [latent.id + '', 0], vae: [vae.id + '', 0] }, class_type: 'VAEDecode' }, [preview.id]: { inputs: { images: [decode.id + '', 0] }, class_type: 'PreviewImage' } } expect((await graph.toPrompt()).output).toEqual(output) // Ensure missing connections dont cause errors group2.inputs.VAE.disconnect() delete output[decode.id].inputs.vae expect((await graph.toPrompt()).output).toEqual(output) }) test('displays generated image on group node', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) let group = await convertToGroup(app, graph, 'test', [ nodes.pos, nodes.neg, nodes.empty, nodes.sampler, nodes.decode, nodes.save ]) const { api } = await import('../../src/scripts/api') api.dispatchEvent(new CustomEvent('execution_start', {})) api.dispatchEvent( new CustomEvent('executing', { detail: `${nodes.save.id}` }) ) // Event should be forwarded to group node id expect(+app.runningNodeId).toEqual(group.id) expect(group.node['imgs']).toBeFalsy() api.dispatchEvent( new CustomEvent('executed', { detail: { node: `${nodes.save.id}`, display_node: `${nodes.save.id}`, output: { images: [ { filename: 'test.png', type: 'output' } ] } } }) ) // Trigger paint group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas) expect(group.node['images']).toEqual([ { filename: 'test.png', type: 'output' } ]) // Reload const workflow = JSON.stringify((await graph.toPrompt()).workflow) await app.loadGraphData(JSON.parse(workflow)) group = graph.find(group) // Trigger inner nodes to get created group.node['getInnerNodes']() // Check it works for internal node ids api.dispatchEvent(new CustomEvent('execution_start', {})) api.dispatchEvent(new CustomEvent('executing', { detail: `${group.id}:5` })) // Event should be forwarded to group node id expect(+app.runningNodeId).toEqual(group.id) expect(group.node['imgs']).toBeFalsy() api.dispatchEvent( new CustomEvent('executed', { detail: { node: `${group.id}:5`, display_node: `${group.id}:5`, output: { images: [ { filename: 'test2.png', type: 'output' } ] } } }) ) // Trigger paint group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas) expect(group.node['images']).toEqual([ { filename: 'test2.png', type: 'output' } ]) }) test('allows widgets to be converted to inputs', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) const group = await convertToGroup(app, graph, 'test', [ nodes.pos, nodes.neg ]) group.widgets[0].convertToInput() const primitive = ez.PrimitiveNode() primitive.outputs[0].connectTo(group.inputs['text']) primitive.widgets[0].value = 'hello' expect((await graph.toPrompt()).output).toEqual( getOutput([nodes.pos.id, nodes.neg.id], { [nodes.pos.id]: { text: 'hello' } }) ) }) test('can be copied', async () => { const { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) const group1 = await convertToGroup(app, graph, 'test', [ nodes.pos, nodes.neg, nodes.empty, nodes.sampler, nodes.decode, nodes.save ]) group1.widgets['text'].value = 'hello' group1.widgets['width'].value = 256 group1.widgets['seed'].value = 1 // Clone the node group1.menu.Clone.call() expect(app.graph._nodes).toHaveLength(3) const group2 = graph.find(app.graph._nodes[2]) expect(group2.node.type).toEqual('workflow/test') expect(group2.id).not.toEqual(group1.id) // Reconnect ckpt nodes.ckpt.outputs.MODEL.connectTo(group2.inputs['model']) nodes.ckpt.outputs.CLIP.connectTo(group2.inputs['clip']) nodes.ckpt.outputs.CLIP.connectTo(group2.inputs['CLIPTextEncode clip']) nodes.ckpt.outputs.VAE.connectTo(group2.inputs['vae']) group2.widgets['text'].value = 'world' group2.widgets['width'].value = 1024 group2.widgets['seed'].value = 100 let i = 0 expect((await graph.toPrompt()).output).toEqual({ ...getOutput( [ nodes.empty.id, nodes.pos.id, nodes.neg.id, nodes.sampler.id, nodes.decode.id, nodes.save.id ], { [nodes.empty.id]: { width: 256 }, [nodes.pos.id]: { text: 'hello' }, [nodes.sampler.id]: { seed: 1 } } ), ...getOutput( { [nodes.empty.id]: `${group2.id}:${i++}`, [nodes.pos.id]: `${group2.id}:${i++}`, [nodes.neg.id]: `${group2.id}:${i++}`, [nodes.sampler.id]: `${group2.id}:${i++}`, [nodes.decode.id]: `${group2.id}:${i++}`, [nodes.save.id]: `${group2.id}:${i++}` }, { [nodes.empty.id]: { width: 1024 }, [nodes.pos.id]: { text: 'world' }, [nodes.sampler.id]: { seed: 100 } } ) }) graph.arrange() }) test('is embedded in workflow', async () => { let { ez, graph, app } = await start() const nodes = createDefaultWorkflow(ez, graph) let group = await convertToGroup(app, graph, 'test', [nodes.pos, nodes.neg]) const workflow = JSON.stringify((await graph.toPrompt()).workflow) // Clear the environment ;({ ez, graph, app } = await start({ resetEnv: true })) // Ensure the node isnt registered expect(() => ez['workflow/test']).toThrow() // Reload the workflow await app.loadGraphData(JSON.parse(workflow)) // Ensure the node is found group = graph.find(group) // Generate prompt and ensure it is as expected expect((await graph.toPrompt()).output).toEqual( getOutput({ [nodes.pos.id]: `${group.id}:0`, [nodes.neg.id]: `${group.id}:1` }) ) }) // Now reports zod validation error test.skip('shows missing node error on missing internal node when loading graph data', async () => { const { graph } = await start() const dialogShow = jest.spyOn(graph.app.ui.dialog, 'show') await graph.app.loadGraphData({ last_node_id: 3, last_link_id: 1, nodes: [ { id: 3, type: 'workflow/testerror' } ], links: [], groups: [], config: {}, extra: { groupNodes: { testerror: { nodes: [ { type: 'NotKSampler' }, { type: 'NotVAEDecode' } ] } } } }) expect(dialogShow).toBeCalledTimes(1) // @ts-expect-error const call = dialogShow.mock.calls[0][0].innerHTML expect(call).toContain('the following node types were not found') expect(call).toContain('NotKSampler') expect(call).toContain('NotVAEDecode') expect(call).toContain('workflow/testerror') }) test('maintains widget inputs on conversion back to nodes', async () => { const { ez, graph, app } = await start() let pos = ez.CLIPTextEncode({ text: 'positive' }) pos.node.title = 'Positive' let neg = ez.CLIPTextEncode({ text: 'negative' }) neg.node.title = 'Negative' pos.widgets.text.convertToInput() neg.widgets.text.convertToInput() let primitive = ez.PrimitiveNode() primitive.outputs[0].connectTo(pos.inputs.text) primitive.outputs[0].connectTo(neg.inputs.text) const group = await convertToGroup(app, graph, 'test', [ pos, neg, primitive ]) // This will use a primitive widget named 'value' expect(group.widgets.length).toBe(1) expect(group.widgets['value'].value).toBe('positive') const newNodes = group.menu['Convert to nodes'].call() pos = graph.find(newNodes.find((n) => n.title === 'Positive')) neg = graph.find(newNodes.find((n) => n.title === 'Negative')) primitive = graph.find(newNodes.find((n) => n.type === 'PrimitiveNode')) expect(pos.inputs).toHaveLength(2) expect(neg.inputs).toHaveLength(2) expect(primitive.outputs[0].connections).toHaveLength(2) expect((await graph.toPrompt()).output).toEqual({ 1: { inputs: { text: 'positive' }, class_type: 'CLIPTextEncode' }, 2: { inputs: { text: 'positive' }, class_type: 'CLIPTextEncode' } }) }) test('correctly handles widget inputs', async () => { const { ez, graph, app } = await start() const upscaleMethods = (await getNodeDef('ImageScaleBy')).input.required[ 'upscale_method' ][0] const image = ez.LoadImage() const scale1 = ez.ImageScaleBy(image.outputs[0]) const scale2 = ez.ImageScaleBy(image.outputs[0]) const preview1 = ez.PreviewImage(scale1.outputs[0]) const preview2 = ez.PreviewImage(scale2.outputs[0]) scale1.widgets.upscale_method.value = upscaleMethods[1] scale1.widgets.upscale_method.convertToInput() const group = await convertToGroup(app, graph, 'test', [scale1, scale2]) expect(group.inputs.length).toBe(3) expect(group.inputs[0].input.type).toBe('IMAGE') expect(group.inputs[1].input.type).toBe('IMAGE') expect(group.inputs[2].input.type).toBe('COMBO') // Ensure links are maintained expect(group.inputs[0].connection?.originNode?.id).toBe(image.id) expect(group.inputs[1].connection?.originNode?.id).toBe(image.id) expect(group.inputs[2].connection).toBeFalsy() // Ensure primitive gets correct type const primitive = ez.PrimitiveNode() primitive.outputs[0].connectTo(group.inputs[2]) expect(primitive.widgets.value.widget.options.values).toBe(upscaleMethods) expect(primitive.widgets.value.value).toBe(upscaleMethods[1]) // Ensure value is copied primitive.widgets.value.value = upscaleMethods[1] await checkBeforeAndAfterReload(graph, async (r) => { const scale1id = r ? `${group.id}:0` : scale1.id const scale2id = r ? `${group.id}:1` : scale2.id // Ensure widget value is applied to prompt expect((await graph.toPrompt()).output).toStrictEqual({ [image.id]: { inputs: { image: 'example.png', upload: 'image' }, class_type: 'LoadImage' }, [scale1id]: { inputs: { upscale_method: upscaleMethods[1], scale_by: 1, image: [`${image.id}`, 0] }, class_type: 'ImageScaleBy' }, [scale2id]: { inputs: { upscale_method: 'nearest-exact', scale_by: 1, image: [`${image.id}`, 0] }, class_type: 'ImageScaleBy' }, [preview1.id]: { inputs: { images: [`${scale1id}`, 0] }, class_type: 'PreviewImage' }, [preview2.id]: { inputs: { images: [`${scale2id}`, 0] }, class_type: 'PreviewImage' } }) }) }) test('adds widgets in node execution order', async () => { const { ez, graph, app } = await start() const scale = ez.LatentUpscale() const save = ez.SaveImage() const empty = ez.EmptyLatentImage() const decode = ez.VAEDecode() scale.outputs.LATENT.connectTo(decode.inputs.samples) decode.outputs.IMAGE.connectTo(save.inputs.images) empty.outputs.LATENT.connectTo(scale.inputs.samples) const group = await convertToGroup(app, graph, 'test', [ scale, save, empty, decode ]) const widgets = group.widgets.map((w) => w.widget.name) expect(widgets).toStrictEqual([ 'width', 'height', 'batch_size', 'upscale_method', 'LatentUpscale width', 'LatentUpscale height', 'crop', 'filename_prefix' ]) }) test('adds output for external links when converting to group', async () => { const { ez, graph, app } = await start() const img = ez.EmptyLatentImage() let decode = ez.VAEDecode(...img.outputs) const preview1 = ez.PreviewImage(...decode.outputs) const preview2 = ez.PreviewImage(...decode.outputs) const group = await convertToGroup(app, graph, 'test', [ img, decode, preview1 ]) // Ensure we have an output connected to the 2nd preview node expect(group.outputs.length).toBe(1) expect(group.outputs[0].connections.length).toBe(1) expect(group.outputs[0].connections[0].targetNode.id).toBe(preview2.id) // Convert back and ensure both previews are still connected group.menu['Convert to nodes'].call() decode = graph.find(decode) expect(decode.outputs[0].connections.length).toBe(2) expect(decode.outputs[0].connections[0].targetNode.id).toBe(preview1.id) expect(decode.outputs[0].connections[1].targetNode.id).toBe(preview2.id) }) test('adds output for external links when converting to group when nodes are not in execution order', async () => { const { ez, graph, app } = await start() const sampler = ez.KSampler() const ckpt = ez.CheckpointLoaderSimple() const empty = ez.EmptyLatentImage() const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: 'positive' }) const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: 'negative' }) const decode1 = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE) const save = ez.SaveImage(decode1.outputs.IMAGE) ckpt.outputs.MODEL.connectTo(sampler.inputs.model) pos.outputs.CONDITIONING.connectTo(sampler.inputs.positive) neg.outputs.CONDITIONING.connectTo(sampler.inputs.negative) empty.outputs.LATENT.connectTo(sampler.inputs.latent_image) const encode = ez.VAEEncode(decode1.outputs.IMAGE) const vae = ez.VAELoader() const decode2 = ez.VAEDecode(encode.outputs.LATENT, vae.outputs.VAE) const preview = ez.PreviewImage(decode2.outputs.IMAGE) vae.outputs.VAE.connectTo(encode.inputs.vae) const group = await convertToGroup(app, graph, 'test', [ vae, decode1, encode, sampler ]) expect(group.outputs.length).toBe(3) expect(group.outputs[0].output.name).toBe('VAE') expect(group.outputs[0].output.type).toBe('VAE') expect(group.outputs[1].output.name).toBe('IMAGE') expect(group.outputs[1].output.type).toBe('IMAGE') expect(group.outputs[2].output.name).toBe('LATENT') expect(group.outputs[2].output.type).toBe('LATENT') expect(group.outputs[0].connections.length).toBe(1) expect(group.outputs[0].connections[0].targetNode.id).toBe(decode2.id) expect(group.outputs[0].connections[0].targetInput.index).toBe(1) expect(group.outputs[1].connections.length).toBe(1) expect(group.outputs[1].connections[0].targetNode.id).toBe(save.id) expect(group.outputs[1].connections[0].targetInput.index).toBe(0) expect(group.outputs[2].connections.length).toBe(1) expect(group.outputs[2].connections[0].targetNode.id).toBe(decode2.id) expect(group.outputs[2].connections[0].targetInput.index).toBe(0) expect((await graph.toPrompt()).output).toEqual({ ...getOutput({ 1: ckpt.id, 2: pos.id, 3: neg.id, 4: empty.id, 5: sampler.id, 6: decode1.id, 7: save.id }), [vae.id]: { inputs: { vae_name: 'vae1.safetensors' }, class_type: vae.node.type }, [encode.id]: { inputs: { pixels: ['6', 0], vae: [vae.id + '', 0] }, class_type: encode.node.type }, [decode2.id]: { inputs: { samples: [encode.id + '', 0], vae: [vae.id + '', 0] }, class_type: decode2.node.type }, [preview.id]: { inputs: { images: [decode2.id + '', 0] }, class_type: preview.node.type } }) }) test('works with IMAGEUPLOAD widget', async () => { const { ez, graph, app } = await start() const img = ez.LoadImage() const preview1 = ez.PreviewImage(img.outputs[0]) const group = await convertToGroup(app, graph, 'test', [img, preview1]) const widget = group.widgets['upload'] expect(widget).toBeTruthy() expect(widget.widget.type).toBe('button') }) test('internal primitive populates widgets for all linked inputs', async () => { const { ez, graph, app } = await start() const img = ez.LoadImage() const scale1 = ez.ImageScale(img.outputs[0]) const scale2 = ez.ImageScale(img.outputs[0]) ez.PreviewImage(scale1.outputs[0]) ez.PreviewImage(scale2.outputs[0]) scale1.widgets.width.convertToInput() scale2.widgets.height.convertToInput() const primitive = ez.PrimitiveNode() primitive.outputs[0].connectTo(scale1.inputs.width) primitive.outputs[0].connectTo(scale2.inputs.height) const group = await convertToGroup(app, graph, 'test', [ img, primitive, scale1, scale2 ]) group.widgets.value.value = 100 expect((await graph.toPrompt()).output).toEqual({ 1: { inputs: { image: img.widgets.image.value, upload: 'image' }, class_type: 'LoadImage' }, 2: { inputs: { upscale_method: 'nearest-exact', width: 100, height: 512, crop: 'disabled', image: ['1', 0] }, class_type: 'ImageScale' }, 3: { inputs: { upscale_method: 'nearest-exact', width: 512, height: 100, crop: 'disabled', image: ['1', 0] }, class_type: 'ImageScale' }, 4: { inputs: { images: ['2', 0] }, class_type: 'PreviewImage' }, 5: { inputs: { images: ['3', 0] }, class_type: 'PreviewImage' } }) }) test('primitive control widgets values are copied on convert', async () => { const { ez, graph, app } = await start() const sampler = ez.KSampler() sampler.widgets.seed.convertToInput() sampler.widgets.sampler_name.convertToInput() let p1 = ez.PrimitiveNode() let p2 = ez.PrimitiveNode() p1.outputs[0].connectTo(sampler.inputs.seed) p2.outputs[0].connectTo(sampler.inputs.sampler_name) p1.widgets.control_after_generate.value = 'increment' p2.widgets.control_after_generate.value = 'decrement' p2.widgets.control_filter_list.value = '/.*/' p2.node.title = 'p2' const group = await convertToGroup(app, graph, 'test', [sampler, p1, p2]) expect(group.widgets.control_after_generate.value).toBe('increment') expect(group.widgets['p2 control_after_generate'].value).toBe('decrement') expect(group.widgets['p2 control_filter_list'].value).toBe('/.*/') group.widgets.control_after_generate.value = 'fixed' group.widgets['p2 control_after_generate'].value = 'randomize' group.widgets['p2 control_filter_list'].value = '/.+/' group.menu['Convert to nodes'].call() p1 = graph.find(p1) p2 = graph.find(p2) expect(p1.widgets.control_after_generate.value).toBe('fixed') expect(p2.widgets.control_after_generate.value).toBe('randomize') expect(p2.widgets.control_filter_list.value).toBe('/.+/') }) test('internal reroutes work with converted inputs and merge options', async () => { const { ez, graph, app } = await start() const vae = ez.VAELoader() const latent = ez.EmptyLatentImage() const decode = ez.VAEDecode(latent.outputs.LATENT, vae.outputs.VAE) const scale = ez.ImageScale(decode.outputs.IMAGE) ez.PreviewImage(scale.outputs.IMAGE) const r1 = ez.Reroute() const r2 = ez.Reroute() latent.widgets.width.value = 64 latent.widgets.height.value = 128 latent.widgets.width.convertToInput() latent.widgets.height.convertToInput() latent.widgets.batch_size.convertToInput() scale.widgets.width.convertToInput() scale.widgets.height.convertToInput() r1.inputs[0].input.label = 'hbw' r1.outputs[0].connectTo(latent.inputs.height) r1.outputs[0].connectTo(latent.inputs.batch_size) r1.outputs[0].connectTo(scale.inputs.width) r2.inputs[0].input.label = 'wh' r2.outputs[0].connectTo(latent.inputs.width) r2.outputs[0].connectTo(scale.inputs.height) const group = await convertToGroup(app, graph, 'test', [ r1, r2, latent, decode, scale ]) expect(group.inputs[0].input.type).toBe('VAE') expect(group.inputs[1].input.type).toBe('INT') expect(group.inputs[2].input.type).toBe('INT') const p1 = ez.PrimitiveNode() const p2 = ez.PrimitiveNode() p1.outputs[0].connectTo(group.inputs[1]) p2.outputs[0].connectTo(group.inputs[2]) expect(p1.widgets.value.widget.options?.min).toBe(16) // width/height min expect(p1.widgets.value.widget.options?.max).toBe(4096) // batch max expect(p1.widgets.value.widget.options?.step).toBe(80) // width/height step * 10 expect(p2.widgets.value.widget.options?.min).toBe(16) // width/height min expect(p2.widgets.value.widget.options?.max).toBe(16384) // width/height max expect(p2.widgets.value.widget.options?.step).toBe(80) // width/height step * 10 expect(p1.widgets.value.value).toBe(128) expect(p2.widgets.value.value).toBe(64) p1.widgets.value.value = 16 p2.widgets.value.value = 32 await checkBeforeAndAfterReload(graph, async (r) => { const id = (v) => (r ? `${group.id}:` : '') + v expect((await graph.toPrompt()).output).toStrictEqual({ 1: { inputs: { vae_name: 'vae1.safetensors' }, class_type: 'VAELoader' }, [id(2)]: { inputs: { width: 32, height: 16, batch_size: 16 }, class_type: 'EmptyLatentImage' }, [id(3)]: { inputs: { samples: [id(2), 0], vae: ['1', 0] }, class_type: 'VAEDecode' }, [id(4)]: { inputs: { upscale_method: 'nearest-exact', width: 16, height: 32, crop: 'disabled', image: [id(3), 0] }, class_type: 'ImageScale' }, 5: { inputs: { images: [id(4), 0] }, class_type: 'PreviewImage' } }) }) }) test('converted inputs with linked widgets map values correctly on creation', async () => { const { ez, graph, app } = await start() const k1 = ez.KSampler() const k2 = ez.KSampler() k1.widgets.seed.convertToInput() k2.widgets.seed.convertToInput() const rr = ez.Reroute() rr.outputs[0].connectTo(k1.inputs.seed) rr.outputs[0].connectTo(k2.inputs.seed) const group = await convertToGroup(app, graph, 'test', [k1, k2, rr]) expect(group.widgets.steps.value).toBe(20) expect(group.widgets.cfg.value).toBe(8) expect(group.widgets.scheduler.value).toBe('normal') expect(group.widgets['KSampler steps'].value).toBe(20) expect(group.widgets['KSampler cfg'].value).toBe(8) expect(group.widgets['KSampler scheduler'].value).toBe('normal') }) test('allow multiple of the same node type to be added', async () => { const { ez, graph, app } = await start() const nodes = [...Array(10)].map(() => ez.ImageScaleBy()) const group = await convertToGroup(app, graph, 'test', nodes) expect(group.inputs.length).toBe(10) expect(group.outputs.length).toBe(10) expect(group.widgets.length).toBe(20) expect(group.widgets.map((w) => w.widget.name)).toStrictEqual( [...Array(10)] .map((_, i) => `${i > 0 ? 'ImageScaleBy ' : ''}${i > 1 ? i + ' ' : ''}`) .flatMap((p) => [`${p}upscale_method`, `${p}scale_by`]) ) }) })