From 3070ed36763772ce775e5a901380ad0d6b6e62e3 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Sun, 14 May 2023 12:29:49 -0500 Subject: [PATCH 01/38] Refactor prompt serializer --- src/lib/components/ComfyApp.ts | 176 +---------------- src/lib/components/ComfyPromptSerializer.ts | 197 ++++++++++++++++++++ src/lib/nodes/ComfyBackendNode.ts | 2 +- 3 files changed, 202 insertions(+), 173 deletions(-) create mode 100644 src/lib/components/ComfyPromptSerializer.ts diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index acb560f..e5b21e4 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -33,6 +33,7 @@ import notify from "$lib/notify"; import configState from "$lib/stores/configState"; import { blankGraph } from "$lib/defaultGraph"; import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes"; +import ComfyPromptSerializer from "./ComfyPromptSerializer"; export const COMFYBOX_SERIAL_VERSION = 1; @@ -84,27 +85,6 @@ type BackendComboNode = { backendNode: ComfyBackendNode } -function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): boolean { - if (!node.isBackendNode) - return false; - - if (tag && !hasTag(node, tag)) { - console.debug("Skipping tagged node", tag, node.properties.tags, node) - return false; - } - - if (node.mode === NodeMode.NEVER) { - // Don't serialize muted nodes - return false; - } - - return true; -} - -function hasTag(node: LGraphNode, tag: string): boolean { - return "tags" in node.properties && node.properties.tags.indexOf(tag) !== -1 -} - export default class ComfyApp { api: ComfyAPI; rootEl: HTMLDivElement | null = null; @@ -122,9 +102,11 @@ export default class ComfyApp { private queueItems: QueueItem[] = []; private processingQueue: boolean = false; private alreadySetup = false; + private promptSerializer: ComfyPromptSerializer; constructor() { this.api = new ComfyAPI(); + this.promptSerializer = new ComfyPromptSerializer(); } async setup(): Promise { @@ -559,157 +541,7 @@ export default class ComfyApp { * @returns The workflow and node links */ graphToPrompt(tag: string | null = null): SerializedPrompt { - // Run frontend-only logic - this.lGraph.runStep(1) - - const workflow = this.lGraph.serialize(); - - const output = {}; - // Process nodes in order of execution - for (const node_ of this.lGraph.computeExecutionOrder(false, null)) { - const n = workflow.nodes.find((n) => n.id === node_.id); - - if (!isActiveBackendNode(node_, tag)) { - continue; - } - - const node = node_ as ComfyBackendNode; - - const inputs = {}; - - // Store input values passed by frontend-only nodes - if (node.inputs) { - for (let i = 0; i < node.inputs.length; i++) { - const inp = node.inputs[i]; - const inputLink = node.getInputLink(i) - const inputNode = node.getInputNode(i) - - // We don't check tags for non-backend nodes. - // Just check for node inactivity (so you can toggle groups of - // tagged frontend nodes on/off) - if (inputNode && inputNode.mode === NodeMode.NEVER) { - console.debug("Skipping inactive node", inputNode) - continue; - } - - if (!inputLink || !inputNode) { - if ("config" in inp) { - const defaultValue = (inp as IComfyInputSlot).config?.defaultValue - if (defaultValue !== null && defaultValue !== undefined) - inputs[inp.name] = defaultValue - } - continue; - } - - let serialize = true; - if ("config" in inp) - serialize = (inp as IComfyInputSlot).serialize - - let isBackendNode = node.isBackendNode; - let isInputBackendNode = false; - if ("isBackendNode" in inputNode) - isInputBackendNode = (inputNode as ComfyGraphNode).isBackendNode; - - // The reasoning behind this check: - // We only want to serialize inputs to nodes with backend equivalents. - // And in ComfyBox, the backend nodes in litegraph *never* have widgets, instead they're all inputs. - // All values are passed by separate frontend-only nodes, - // either UI-bound or something like ConstantInteger. - // So we know that any value passed into a backend node *must* come from - // a frontend node. - // The rest (links between backend nodes) will be serialized after this bit runs. - if (serialize && isBackendNode && !isInputBackendNode) { - inputs[inp.name] = inputLink.data - } - } - } - - // Store links between backend-only and hybrid nodes - for (let i = 0; i < node.inputs.length; i++) { - let parent: ComfyGraphNode = node.getInputNode(i) as ComfyGraphNode; - if (parent) { - const seen = {} - let link = node.getInputLink(i); - - const isFrontendParent = (parent: ComfyGraphNode) => { - if (!parent || parent.isBackendNode) - return false; - if (tag && !hasTag(parent, tag)) - return false; - return true; - } - - // If there are frontend-only nodes between us and another - // backend node, we have to traverse them first. This - // behavior is dependent on the type of node. Reroute nodes - // will simply follow their single input, while branching - // nodes have conditional logic that determines which link - // to follow backwards. - while (isFrontendParent(parent)) { - if (!("getUpstreamLink" in parent)) { - console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type) - break; - } - - const nextLink = parent.getUpstreamLink() - if (nextLink == null) { - console.warn("[graphToPrompt] No upstream link found in frontend node", parent) - break; - } - - if (nextLink && !seen[nextLink.id]) { - seen[nextLink.id] = true - const inputNode = parent.graph.getNodeById(nextLink.origin_id) as ComfyGraphNode; - if (inputNode && tag && !hasTag(inputNode, tag)) { - console.debug("[graphToPrompt] Skipping tagged intermediate frontend node", tag, node.properties.tags) - parent = null; - } - else { - console.debug("[graphToPrompt] Traverse upstream link", parent.id, inputNode?.id, inputNode?.isBackendNode) - link = nextLink; - parent = inputNode; - } - } else { - parent = null; - } - } - - if (link && parent && parent.isBackendNode) { - if (tag && !hasTag(parent, tag)) - continue; - - console.debug("[graphToPrompt] final link", parent.id, node.id) - const input = node.inputs[i] - // TODO can null be a legitimate value in some cases? - // Nodes like CLIPLoader will never have a value in the frontend, hence "null". - if (!(input.name in inputs)) - inputs[input.name] = [String(link.origin_id), link.origin_slot]; - } - } - } - - output[String(node.id)] = { - inputs, - class_type: node.comfyClass, - }; - } - - // Remove inputs connected to removed nodes - for (const nodeId in output) { - for (const inputName in output[nodeId].inputs) { - if (Array.isArray(output[nodeId].inputs[inputName]) - && output[nodeId].inputs[inputName].length === 2 - && !output[output[nodeId].inputs[inputName][0]]) { - console.debug("Prune removed node link", nodeId, inputName, output[nodeId].inputs[inputName]) - delete output[nodeId].inputs[inputName]; - } - } - } - - // console.debug({ workflow, output }) - // console.debug(promptToGraphVis({ workflow, output })) - - return { workflow, output }; + return this.promptSerializer.serializePrompt(this.lGraph, tag) } async queuePrompt(num: number, batchCount: number = 1, tag: string | null = null) { diff --git a/src/lib/components/ComfyPromptSerializer.ts b/src/lib/components/ComfyPromptSerializer.ts new file mode 100644 index 0000000..46fe181 --- /dev/null +++ b/src/lib/components/ComfyPromptSerializer.ts @@ -0,0 +1,197 @@ +import type ComfyGraph from "$lib/ComfyGraph"; +import type { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; +import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; +import { LGraphNode, NodeMode } from "@litegraph-ts/core"; +import type { SerializedPrompt, SerializedPromptInput, SerializedPromptInputs, SerializedPromptInputsAll } from "./ComfyApp"; +import type IComfyInputSlot from "$lib/IComfyInputSlot"; + +function hasTag(node: LGraphNode, tag: string): boolean { + return "tags" in node.properties && node.properties.tags.indexOf(tag) !== -1 +} + +function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): node is ComfyBackendNode { + if (!node.isBackendNode) + return false; + + if (tag && !hasTag(node, tag)) { + console.debug("Skipping tagged node", tag, node.properties.tags, node) + return false; + } + + if (node.mode === NodeMode.NEVER) { + // Don't serialize muted nodes + return false; + } + + return true; +} + +export default class ComfyPromptSerializer { + serializeInputValues(node: ComfyBackendNode): Record { + // Store input values passed by frontend-only nodes + if (!node.inputs) { + return {} + } + + const inputs = {} + + for (let i = 0; i < node.inputs.length; i++) { + const inp = node.inputs[i]; + const inputLink = node.getInputLink(i) + const inputNode = node.getInputNode(i) + + // We don't check tags for non-backend nodes. + // Just check for node inactivity (so you can toggle groups of + // tagged frontend nodes on/off) + if (inputNode && inputNode.mode === NodeMode.NEVER) { + console.debug("Skipping inactive node", inputNode) + continue; + } + + if (!inputLink || !inputNode) { + if ("config" in inp) { + const defaultValue = (inp as IComfyInputSlot).config?.defaultValue + if (defaultValue !== null && defaultValue !== undefined) + inputs[inp.name] = defaultValue + } + continue; + } + + let serialize = true; + if ("config" in inp) + serialize = (inp as IComfyInputSlot).serialize + + let isBackendNode = node.isBackendNode; + let isInputBackendNode = false; + if ("isBackendNode" in inputNode) + isInputBackendNode = (inputNode as ComfyGraphNode).isBackendNode; + + // The reasoning behind this check: + // We only want to serialize inputs to nodes with backend equivalents. + // And in ComfyBox, the backend nodes in litegraph *never* have widgets, instead they're all inputs. + // All values are passed by separate frontend-only nodes, + // either UI-bound or something like ConstantInteger. + // So we know that any value passed into a backend node *must* come from + // a frontend node. + // The rest (links between backend nodes) will be serialized after this bit runs. + if (serialize && isBackendNode && !isInputBackendNode) { + inputs[inp.name] = inputLink.data + } + } + + return inputs + } + + serializeBackendLinks(node: ComfyBackendNode, tag: string | null): Record { + const inputs = {} + + // Store links between backend-only and hybrid nodes + for (let i = 0; i < node.inputs.length; i++) { + let parent: ComfyGraphNode = node.getInputNode(i) as ComfyGraphNode; + if (parent) { + const seen = {} + let link = node.getInputLink(i); + + const isFrontendParent = (parent: ComfyGraphNode) => { + if (!parent || parent.isBackendNode) + return false; + if (tag && !hasTag(parent, tag)) + return false; + return true; + } + + // If there are frontend-only nodes between us and another + // backend node, we have to traverse them first. This + // behavior is dependent on the type of node. Reroute nodes + // will simply follow their single input, while branching + // nodes have conditional logic that determines which link + // to follow backwards. + while (isFrontendParent(parent)) { + if (!("getUpstreamLink" in parent)) { + console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type) + break; + } + + const nextLink = parent.getUpstreamLink() + if (nextLink == null) { + console.warn("[graphToPrompt] No upstream link found in frontend node", parent) + break; + } + + if (nextLink && !seen[nextLink.id]) { + seen[nextLink.id] = true + const inputNode = parent.graph.getNodeById(nextLink.origin_id) as ComfyGraphNode; + if (inputNode && tag && !hasTag(inputNode, tag)) { + console.debug("[graphToPrompt] Skipping tagged intermediate frontend node", tag, node.properties.tags) + parent = null; + } + else { + console.debug("[graphToPrompt] Traverse upstream link", parent.id, inputNode?.id, inputNode?.isBackendNode) + link = nextLink; + parent = inputNode; + } + } else { + parent = null; + } + } + + if (link && parent && parent.isBackendNode) { + if (tag && !hasTag(parent, tag)) + continue; + + console.debug("[graphToPrompt] final link", parent.id, node.id) + const input = node.inputs[i] + // TODO can null be a legitimate value in some cases? + // Nodes like CLIPLoader will never have a value in the frontend, hence "null". + if (!(input.name in inputs)) + inputs[input.name] = [String(link.origin_id), link.origin_slot]; + } + } + } + + return inputs + } + + serializePrompt(graph: ComfyGraph, tag: string | null = null): SerializedPrompt { + // Run frontend-only logic + graph.runStep(1) + + const workflow = graph.serialize(); + + const output: SerializedPromptInputsAll = {}; + + // Process nodes in order of execution + for (const node of graph.computeExecutionOrder(false, null)) { + const n = workflow.nodes.find((n) => n.id === node.id); + + if (!isActiveBackendNode(node, tag)) { + continue; + } + + const inputs = this.serializeInputValues(node); + const links = this.serializeBackendLinks(node, tag); + + output[String(node.id)] = { + inputs: { ...inputs, ...links }, + class_type: node.comfyClass, + }; + } + + // Remove inputs connected to removed nodes + for (const nodeId in output) { + for (const inputName in output[nodeId].inputs) { + if (Array.isArray(output[nodeId].inputs[inputName]) + && output[nodeId].inputs[inputName].length === 2 + && !output[output[nodeId].inputs[inputName][0]]) { + console.debug("Prune removed node link", nodeId, inputName, output[nodeId].inputs[inputName]) + delete output[nodeId].inputs[inputName]; + } + } + } + + // console.debug({ workflow, output }) + // console.debug(promptToGraphVis({ workflow, output })) + + return { workflow, output }; + } +} diff --git a/src/lib/nodes/ComfyBackendNode.ts b/src/lib/nodes/ComfyBackendNode.ts index a75c968..fd17f00 100644 --- a/src/lib/nodes/ComfyBackendNode.ts +++ b/src/lib/nodes/ComfyBackendNode.ts @@ -30,7 +30,7 @@ export class ComfyBackendNode extends ComfyGraphNode { // It just returns a hash like { "ui": { "images": results } } internally. // So this will need to be hardcoded for now. if (["PreviewImage", "SaveImage"].indexOf(comfyClass) !== -1) { - this.addOutput("onExecuted", BuiltInSlotType.EVENT, { color_off: "rebeccapurple", color_on: "rebeccapurple" }); + this.addOutput("OUTPUT", BuiltInSlotType.EVENT, { color_off: "rebeccapurple", color_on: "rebeccapurple" }); } } From 8d1ee2d8d5f6b6b015404dc44e49be37339f4d1b Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 15 May 2023 18:50:09 -0500 Subject: [PATCH 02/38] Update /prompt endpoint --- src/lib/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 2ac50e7..c457de4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -260,7 +260,7 @@ export default class ComfyAPI { }) .then(async (res) => { if (res.status != 200) { - throw await res.text() + throw await res.json() } return res.json() }) From 7ca0ee1e7f27c2f13db54d4ed7cc4ed571652ce5 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 15 May 2023 21:51:37 -0500 Subject: [PATCH 03/38] Begin serializer tests --- klecks | 2 +- litegraph | 2 +- package.json | 4 + pnpm-lock.yaml | 284 ++++++++++++++++++-- src/lib/ComfyGraph.ts | 6 +- src/lib/components/ComfyApp.ts | 2 +- src/lib/components/ComfyPromptSerializer.ts | 47 +++- vite.config.ts | 9 +- 8 files changed, 327 insertions(+), 29 deletions(-) diff --git a/klecks b/klecks index a36de32..7ec2e2d 160000 --- a/klecks +++ b/klecks @@ -1 +1 @@ -Subproject commit a36de3203fb970fac80f72be75a8ceee0cb01819 +Subproject commit 7ec2e2d9d33128b634d57a4ab1aa26f272a97227 diff --git a/litegraph b/litegraph index 95788fd..055438a 160000 --- a/litegraph +++ b/litegraph @@ -1 +1 @@ -Subproject commit 95788fdcfbd48f5c07743b407a166977e9cdf7c8 +Subproject commit 055438a760e68b8efb5ef87a66a7676ca6a6840a diff --git a/package.json b/package.json index e3a4729..f1e98d3 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,14 @@ "build:css": "pollen -c gradio/js/theme/src/pollen.config.cjs && mv src/pollen.css node_modules/@gradio/theme/src" }, "devDependencies": { + "@floating-ui/core": "^1.2.6", + "@floating-ui/dom": "^1.2.8", "@zerodevx/svelte-toast": "^0.9.3", "eslint": "^8.37.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-svelte3": "^4.0.0", + "happy-dom": "^9.18.3", + "jsdom": "^22.0.0", "prettier": "^2.8.7", "prettier-plugin-svelte": "^2.10.0", "sass": "^1.61.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ecaee1..ccc617f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,12 @@ importers: specifier: ^1.0.5 version: 1.0.5(vite@4.3.1) devDependencies: + '@floating-ui/core': + specifier: ^1.2.6 + version: 1.2.6 + '@floating-ui/dom': + specifier: ^1.2.8 + version: 1.2.8 '@zerodevx/svelte-toast': specifier: ^0.9.3 version: 0.9.3(svelte@3.58.0) @@ -134,6 +140,12 @@ importers: eslint-plugin-svelte3: specifier: ^4.0.0 version: 4.0.0(eslint@8.37.0)(svelte@3.58.0) + happy-dom: + specifier: ^9.18.3 + version: 9.18.3 + jsdom: + specifier: ^22.0.0 + version: 22.0.0 prettier: specifier: ^2.8.7 version: 2.8.7 @@ -172,7 +184,7 @@ importers: version: 4.0.8(typescript@5.0.3)(vite@4.3.1) vitest: specifier: ^0.25.8 - version: 0.25.8(sass@1.61.0) + version: 0.25.8(happy-dom@9.18.3)(jsdom@22.0.0)(sass@1.61.0) gradio/client/js: dependencies: @@ -1677,13 +1689,11 @@ packages: /@floating-ui/core@1.2.6: resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==} - dev: false - /@floating-ui/dom@1.2.6: - resolution: {integrity: sha512-02vxFDuvuVPs22iJICacezYJyf7zwwOCWkPNkWNBr1U0Qt1cKFYzWvxts0AmqcOQGwt/3KJWcWIgtbUU38keyw==} + /@floating-ui/dom@1.2.8: + resolution: {integrity: sha512-XLwhYV90MxiHDq6S0rzFZj00fnDM+A1R9jhSioZoMsa7G0Q0i+Q4x40ajR8FHSdYDE1bgjG45mIWe6jtv9UPmg==} dependencies: '@floating-ui/core': 1.2.6 - dev: false /@humanwhocodes/config-array@0.11.8: resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==} @@ -3053,6 +3063,11 @@ packages: tslib: 2.5.0 dev: false + /@tootallnate/once@2.0.0: + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + dev: true + /@trysound/sax@0.2.0: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -3361,6 +3376,10 @@ packages: svelte: 3.58.0 dev: true + /abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + dev: true + /abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: false @@ -3395,6 +3414,15 @@ packages: pako: 2.1.0 dev: false + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -3476,7 +3504,6 @@ packages: /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: false /automation-events@6.0.0: resolution: {integrity: sha512-mSOnckbtuJso8cczB+broNsfcuDIQ+J4GFhlW/V3iD+LAXbS4XGHhdjYvtPnCKclKcvs9cVLtUMUkNncOzUQPg==} @@ -3896,7 +3923,6 @@ packages: engines: {node: '>= 0.8'} dependencies: delayed-stream: 1.0.0 - dev: false /commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4053,6 +4079,10 @@ packages: engines: {node: '>= 6'} dev: false + /css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + dev: true + /cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -4065,6 +4095,13 @@ packages: css-tree: 1.1.3 dev: false + /cssstyle@3.0.0: + resolution: {integrity: sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==} + engines: {node: '>=14'} + dependencies: + rrweb-cssom: 0.6.0 + dev: true + /d3-array@3.2.2: resolution: {integrity: sha512-yEEyEAbDrF8C6Ob2myOBLjwBLck1Z89jMGFee0oPsn95GqjerpaOA4ch+vc2l0FNFFwMD5N7OCSEN5eAlsUbgQ==} engines: {node: '>=12'} @@ -4227,6 +4264,15 @@ packages: engines: {node: '>=4'} dev: false + /data-urls@4.0.0: + resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==} + engines: {node: '>=14'} + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 + dev: true + /datalib@1.9.3: resolution: {integrity: sha512-9rcwGd3zhvmJChyLzL5jjZ6UEtWO0SKa9Ycy6RVoQxSW43TSOBRbizj/Zn8UonfpBjCikHEQrJyE72Xw5eCY5A==} dependencies: @@ -4257,6 +4303,10 @@ packages: dependencies: ms: 2.1.2 + /decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + dev: true + /dedent@0.7.0: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} dev: true @@ -4284,7 +4334,6 @@ packages: /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - dev: false /dequal@2.0.2: resolution: {integrity: sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==} @@ -4353,6 +4402,13 @@ packages: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} dev: false + /domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + dependencies: + webidl-conversions: 7.0.0 + dev: true + /domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} @@ -4415,6 +4471,11 @@ packages: engines: {node: '>=0.12'} dev: false + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: true + /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: @@ -5045,6 +5106,15 @@ packages: mime-types: 2.1.34 dev: false + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.34 + dev: true + /fraction.js@4.2.0: resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} dev: true @@ -5328,6 +5398,17 @@ packages: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} dev: true + /happy-dom@9.18.3: + resolution: {integrity: sha512-b7iMGYeIXvUryNultA0AHEVU0FPpb2djJ/xSVlMDfP7HG4z7FomdqkCEpWtSv1zDL+t1gRUoBbpqFCoUBvjYtg==} + dependencies: + css.escape: 1.5.1 + entities: 4.5.0 + iconv-lite: 0.6.3 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + dev: true + /har-schema@2.0.0: resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} engines: {node: '>=4'} @@ -5365,6 +5446,13 @@ packages: resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==} dev: false + /html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + dependencies: + whatwg-encoding: 2.0.0 + dev: true + /html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true @@ -5423,6 +5511,17 @@ packages: parse-cache-control: 1.0.1 dev: false + /http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + /http-response-object@3.0.2: resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} dependencies: @@ -5438,6 +5537,16 @@ packages: sshpk: 1.17.0 dev: false + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -5448,7 +5557,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: safer-buffer: 2.1.2 - dev: false /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -5565,6 +5673,10 @@ packages: engines: {node: '>=8'} dev: true + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -6136,6 +6248,44 @@ packages: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} dev: false + /jsdom@22.0.0: + resolution: {integrity: sha512-p5ZTEb5h+O+iU02t0GfEjAnkdYPrQSkfuTSMkMYyIoMvUNEHsbG0bHHbfXIcfTqD2UfvjQX7mmgiFsyRwGscVw==} + engines: {node: '>=16'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + abab: 2.0.6 + cssstyle: 3.0.0 + data-urls: 4.0.0 + decimal.js: 10.4.3 + domexception: 4.0.0 + form-data: 4.0.0 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.4 + parse5: 7.1.2 + rrweb-cssom: 0.6.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.2 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 + ws: 8.13.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} engines: {node: '>=4'} @@ -6515,14 +6665,12 @@ packages: /mime-db@1.51.0: resolution: {integrity: sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==} engines: {node: '>= 0.6'} - dev: false /mime-types@2.1.34: resolution: {integrity: sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==} engines: {node: '>= 0.6'} dependencies: mime-db: 1.51.0 - dev: false /mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} @@ -6715,6 +6863,10 @@ packages: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} dev: false + /nwsapi@2.2.4: + resolution: {integrity: sha512-NHj4rzRo0tQdijE9ZqAx6kYDcoRwYwSYzCA8MY3JzfxlrvEU0jhnhJT9BhqhJs7I/dKcrDm6TyulaRqZPIhN5g==} + dev: true + /oauth-sign@0.9.0: resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} dev: false @@ -6860,6 +7012,12 @@ packages: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: true + /path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} dev: false @@ -7114,7 +7272,6 @@ packages: /psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} - dev: false /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} @@ -7136,6 +7293,10 @@ packages: engines: {node: '>=0.6'} dev: false + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7252,6 +7413,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: true + /resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} dev: false @@ -7332,6 +7497,10 @@ packages: optionalDependencies: fsevents: 2.3.2 + /rrweb-cssom@0.6.0: + resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -7370,7 +7539,6 @@ packages: /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - dev: false /sander@0.5.1: resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} @@ -7389,6 +7557,13 @@ packages: immutable: 4.3.0 source-map-js: 1.0.2 + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + /semver@5.7.1: resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} hasBin: true @@ -7782,7 +7957,7 @@ packages: resolution: {integrity: sha512-8Ifi5CD2Ui7FX7NjJRmutFtXjrB8T/FMNoS2H8P81t5LHK4I9G4NIs007rLWG/nRl7y+zJUXa3tWuTjYXw/O5A==} dependencies: '@floating-ui/core': 1.2.6 - '@floating-ui/dom': 1.2.6 + '@floating-ui/dom': 1.2.8 dev: false /svelte-hmr@0.15.1(svelte@3.58.0): @@ -7953,7 +8128,7 @@ packages: /svelte-select@5.5.3: resolution: {integrity: sha512-0QIiEmyon3bqoenFtR/BhamMpMmzOWWg1ctE7xxwP7nEnZhAGGoTra1HPPfEyT6C6gVaOFgCpdBaZoM8DEHlEw==} dependencies: - '@floating-ui/dom': 1.2.6 + '@floating-ui/dom': 1.2.8 svelte-floating-ui: 1.2.8 dev: false @@ -8007,6 +8182,10 @@ packages: ssr-window: 4.0.2 dev: false + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + /sync-request@6.1.0: resolution: {integrity: sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw==} engines: {node: '>=8.0.0'} @@ -8195,10 +8374,27 @@ packages: punycode: 2.3.0 dev: false + /tough-cookie@4.1.2: + resolution: {integrity: sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==} + engines: {node: '>=6'} + dependencies: + psl: 1.9.0 + punycode: 2.3.0 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: false + /tr46@4.1.1: + resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} + engines: {node: '>=14'} + dependencies: + punycode: 2.3.0 + dev: true + /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -8306,6 +8502,11 @@ packages: engines: {node: '>= 4.0.0'} dev: false + /universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: true + /universalify@2.0.0: resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} engines: {node: '>= 10.0.0'} @@ -8325,6 +8526,13 @@ packages: dependencies: punycode: 2.3.0 + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: true + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -9154,7 +9362,7 @@ packages: vite: 4.3.1(sass@1.61.0) dev: false - /vitest@0.25.8(sass@1.61.0): + /vitest@0.25.8(happy-dom@9.18.3)(jsdom@22.0.0)(sass@1.61.0): resolution: {integrity: sha512-X75TApG2wZTJn299E/TIYevr4E9/nBo1sUtZzn0Ci5oK8qnpZAZyhwg0qCeMSakGIWtc6oRwcQFyFfW14aOFWg==} engines: {node: '>=v14.16.0'} hasBin: true @@ -9183,6 +9391,8 @@ packages: acorn-walk: 8.2.0 chai: 4.3.7 debug: 4.3.4 + happy-dom: 9.18.3 + jsdom: 22.0.0 local-pkg: 0.4.3 source-map: 0.6.1 strip-literal: 1.0.1 @@ -9308,6 +9518,13 @@ packages: resolution: {integrity: sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==} dev: false + /w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + dependencies: + xml-name-validator: 4.0.0 + dev: true + /walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} dependencies: @@ -9322,11 +9539,36 @@ packages: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: false + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + /well-known-symbols@2.0.0: resolution: {integrity: sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==} engines: {node: '>=6'} dev: false + /whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + dev: true + + /whatwg-url@12.0.1: + resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==} + engines: {node: '>=14'} + dependencies: + tr46: 4.1.1 + webidl-conversions: 7.0.0 + dev: true + /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: @@ -9405,7 +9647,15 @@ packages: optional: true utf-8-validate: optional: true - dev: false + + /xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + dev: true + + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true /xtend@2.2.0: resolution: {integrity: sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==} diff --git a/src/lib/ComfyGraph.ts b/src/lib/ComfyGraph.ts index b0eaa55..63cd1a9 100644 --- a/src/lib/ComfyGraph.ts +++ b/src/lib/ComfyGraph.ts @@ -116,19 +116,19 @@ export default class ComfyGraph extends LGraph { } } - console.debug("Added", node); + // console.debug("Added", node); this.eventBus.emit("nodeAdded", node); } override onNodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) { layoutState.nodeRemoved(node, options); - console.debug("Removed", node); + // console.debug("Removed", node); this.eventBus.emit("nodeRemoved", node); } override onNodeConnectionChange(kind: LConnectionKind, node: LGraphNode, slot: SlotIndex, targetNode: LGraphNode, targetSlot: SlotIndex) { - console.debug("ConnectionChange", node); + // console.debug("ConnectionChange", node); this.eventBus.emit("nodeConnectionChanged", kind, node, slot, targetNode, targetSlot); } } diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index e5b21e4..821ef64 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -541,7 +541,7 @@ export default class ComfyApp { * @returns The workflow and node links */ graphToPrompt(tag: string | null = null): SerializedPrompt { - return this.promptSerializer.serializePrompt(this.lGraph, tag) + return this.promptSerializer.serialize(this.lGraph, tag) } async queuePrompt(num: number, batchCount: number = 1, tag: string | null = null) { diff --git a/src/lib/components/ComfyPromptSerializer.ts b/src/lib/components/ComfyPromptSerializer.ts index 46fe181..d416a64 100644 --- a/src/lib/components/ComfyPromptSerializer.ts +++ b/src/lib/components/ComfyPromptSerializer.ts @@ -1,7 +1,7 @@ import type ComfyGraph from "$lib/ComfyGraph"; import type { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; -import { LGraphNode, NodeMode } from "@litegraph-ts/core"; +import { GraphInput, LGraphNode, LLink, NodeMode, Subgraph } from "@litegraph-ts/core"; import type { SerializedPrompt, SerializedPromptInput, SerializedPromptInputs, SerializedPromptInputsAll } from "./ComfyApp"; import type IComfyInputSlot from "$lib/IComfyInputSlot"; @@ -26,6 +26,34 @@ function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): node is return true; } +function followSubgraph(subgraph: Subgraph, link: LLink): LLink | null { + if (link.origin_id != subgraph.id) + throw new Error("A!") + + const innerGraphOutput = subgraph.getInnerGraphOutputByIndex(link.origin_slot) + if (innerGraphOutput == null) + throw new Error("No inner graph input!") + + const nextLink = innerGraphOutput.getInputLink(0) + return nextLink; +} + +function followGraphInput(graphInput: GraphInput, link: LLink): LLink | null { + if (link.origin_id != graphInput.id) + throw new Error("A!") + + const outerSubgraph = graphInput.getParentSubgraph(); + if (outerSubgraph == null) + throw new Error("No outer subgraph!") + + const outerInputIndex = outerSubgraph.inputs.findIndex(i => i.name === graphInput.nameInGraph) + if (outerInputIndex == null) + throw new Error("No outer input slot!") + + const nextLink = outerSubgraph.getInputLink(outerInputIndex) + return nextLink; +} + export default class ComfyPromptSerializer { serializeInputValues(node: ComfyBackendNode): Record { // Store input values passed by frontend-only nodes @@ -107,12 +135,21 @@ export default class ComfyPromptSerializer { // nodes have conditional logic that determines which link // to follow backwards. while (isFrontendParent(parent)) { - if (!("getUpstreamLink" in parent)) { + let nextLink = null; + if (parent.is(Subgraph)) { + nextLink = followSubgraph(parent, link); + } + else if (parent.is(GraphInput)) { + nextLink = followGraphInput(parent, link); + } + else if ("getUpstreamLink" in parent) { + nextLink = parent.getUpstreamLink(); + } + else { console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type) break; } - const nextLink = parent.getUpstreamLink() if (nextLink == null) { console.warn("[graphToPrompt] No upstream link found in frontend node", parent) break; @@ -152,7 +189,7 @@ export default class ComfyPromptSerializer { return inputs } - serializePrompt(graph: ComfyGraph, tag: string | null = null): SerializedPrompt { + serialize(graph: ComfyGraph, tag: string | null = null): SerializedPrompt { // Run frontend-only logic graph.runStep(1) @@ -161,7 +198,7 @@ export default class ComfyPromptSerializer { const output: SerializedPromptInputsAll = {}; // Process nodes in order of execution - for (const node of graph.computeExecutionOrder(false, null)) { + for (const node of graph.computeExecutionOrderRecursive(false, null)) { const n = workflow.nodes.find((n) => n.id === node.id); if (!isActiveBackendNode(node, tag)) { diff --git a/vite.config.ts b/vite.config.ts index a38151b..5938dbd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -72,6 +72,13 @@ export default defineConfig({ // } }, test: { - include: ['litegraph/packages/tests/src/main.ts'] + environment: 'jsdom', + deps: { + inline: [/^svelte/, /^@floating-ui/, /dist/, "skeleton-elements", "mdn-polyfills"] + }, + include: [ + 'litegraph/packages/tests/src/main.ts', + 'src/tests/main.ts' + ] } }); From 22aad57bc42279fd09af38b58c8cf8d6de0590e6 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Mon, 15 May 2023 22:26:03 -0500 Subject: [PATCH 04/38] shouldFollowSubgraphs test --- src/tests/ComfyPromptSerializerTests.ts | 134 ++++++++++++++++++++++++ src/tests/UnitTest.ts | 4 + src/tests/main.ts | 78 ++++++++++++++ src/tests/testSuite.ts | 1 + 4 files changed, 217 insertions(+) create mode 100644 src/tests/ComfyPromptSerializerTests.ts create mode 100644 src/tests/UnitTest.ts create mode 100644 src/tests/main.ts create mode 100644 src/tests/testSuite.ts diff --git a/src/tests/ComfyPromptSerializerTests.ts b/src/tests/ComfyPromptSerializerTests.ts new file mode 100644 index 0000000..44723c9 --- /dev/null +++ b/src/tests/ComfyPromptSerializerTests.ts @@ -0,0 +1,134 @@ +import { LGraph, LiteGraph, Subgraph, type SlotLayout } from "@litegraph-ts/core" +import { Watch } from "@litegraph-ts/nodes-basic" +import { expect } from 'vitest' +import UnitTest from "./UnitTest" +import ComfyGraph from "$lib/ComfyGraph"; +import ComfyPromptSerializer from "$lib/components/ComfyPromptSerializer"; +import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; +import ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; + +class MockBackendInput extends ComfyGraphNode { + override isBackendNode = true; + comfyClass: string = "MockBackendInput"; + + static slotLayout: SlotLayout = { + inputs: [ + { name: "in", type: "*" }, + ], + } +} + +LiteGraph.registerNodeType({ + class: MockBackendInput, + title: "Test.MockBackendInput", + desc: "one input", + type: "test/input" +}) + +class MockBackendLink extends ComfyGraphNode { + override isBackendNode = true; + comfyClass: string = "MockBackendLink"; + + static slotLayout: SlotLayout = { + inputs: [ + { name: "in", type: "*" }, + ], + outputs: [ + { name: "out", type: "*" }, + ], + } +} + +LiteGraph.registerNodeType({ + class: MockBackendLink, + title: "Test.MockBackendLink", + desc: "one input, one output", + type: "test/link" +}) + +class MockBackendOutput extends ComfyGraphNode { + override isBackendNode = true; + comfyClass: string = "MockBackendOutput"; + + static slotLayout: SlotLayout = { + outputs: [ + { name: "out", type: "*" }, + ], + } +} + +LiteGraph.registerNodeType({ + class: MockBackendOutput, + title: "Test.MockBackendOutput", + desc: "one output", + type: "test/output" +}) + +export default class ComfyPromptSerializerTests extends UnitTest { + test__serialize__shouldIgnoreFrontend() { + const ser = new ComfyPromptSerializer(); + const graph = new ComfyGraph(); + + const nodeA = LiteGraph.createNode(Watch) + const nodeB = LiteGraph.createNode(Watch) + + graph.add(nodeA) + graph.add(nodeB) + + const result = ser.serialize(graph) + + expect(result.output).toEqual({}) + } + + test__serialize__shouldSerializeBackendNodes() { + const ser = new ComfyPromptSerializer(); + const graph = new ComfyGraph(); + + const input = LiteGraph.createNode(MockBackendInput) + const link = LiteGraph.createNode(MockBackendLink) + const output = LiteGraph.createNode(MockBackendOutput) + + graph.add(input) + graph.add(link) + graph.add(output) + + output.connect(0, link, 0) + link.connect(0, input, 0) + + const result = ser.serialize(graph) + + expect(Object.keys(result.output)).toHaveLength(3); + expect(Object.keys(result.output[input.id].inputs)).toHaveLength(1); + expect(Object.keys(result.output[link.id].inputs)).toHaveLength(1); + expect(Object.keys(result.output[output.id].inputs)).toHaveLength(0); + } + + test__serialize__shouldFollowSubgraphs() { + const ser = new ComfyPromptSerializer(); + const graph = new ComfyGraph(); + + const output = LiteGraph.createNode(MockBackendOutput) + const link = LiteGraph.createNode(MockBackendLink) + const input = LiteGraph.createNode(MockBackendInput) + + const subgraph = LiteGraph.createNode(Subgraph) + const graphInput = subgraph.addGraphInput("testIn", "number") + const graphOutput = subgraph.addGraphOutput("testOut", "number") + + graph.add(output) + subgraph.subgraph.add(link) + graph.add(input) + + output.connect(0, subgraph, 0) + graphInput.innerNode.connect(0, link, 0) + link.connect(0, graphOutput.innerNode, 0) + subgraph.connect(0, input, 0) + + const result = ser.serialize(graph) + + expect(Object.keys(result.output)).toHaveLength(3); + expect(Object.keys(result.output[input.id].inputs)).toHaveLength(1); + expect(Object.keys(result.output[link.id].inputs)).toHaveLength(1); + expect(Object.keys(result.output[output.id].inputs)).toHaveLength(0); + } +} diff --git a/src/tests/UnitTest.ts b/src/tests/UnitTest.ts new file mode 100644 index 0000000..49f3f37 --- /dev/null +++ b/src/tests/UnitTest.ts @@ -0,0 +1,4 @@ +export default abstract class UnitTest { + setUp() { } + tearDown() { } +} diff --git a/src/tests/main.ts b/src/tests/main.ts new file mode 100644 index 0000000..b603b26 --- /dev/null +++ b/src/tests/main.ts @@ -0,0 +1,78 @@ +import { vi, describe, it } from "vitest" +import UnitTest from "./UnitTest" +import * as testSuite from "./testSuite" + +import { LiteGraph } from "@litegraph-ts/core" +import "@litegraph-ts/core" +import "@litegraph-ts/nodes-basic" + +LiteGraph.use_uuids = true; + +// I don't like BDD syntax... +// Emulate minitest instead... +function runTests(ctor: new () => T) { + const instance = new ctor() + const ctorName = instance.constructor.name + const idx = ctorName.indexOf("Tests") + if (idx === -1) { + throw `Invalid test name ${ctorName}, must end with "Tests"` + } + const classCategory = ctorName.substring(0, idx) + describe(classCategory, () => { + const allTopLevelTests: [string, Function][] = [] + const allTests: Record = {} + for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(instance))) { + if (key.startsWith("test")) { + const keys = key.split("__") + let _ = null; + let category = null; + let testName = null; + if (keys.length == 2) { + [_, testName] = keys + } + else { + [_, category, testName] = keys + } + + const value = instance[key] + if (typeof value === "function") { + const testFn = () => { + instance.setUp() + value.apply(instance) + instance.tearDown() + } + + if (category != null) { + allTests[category] ||= [] + allTests[category].push([testName, testFn]) + } + else { + allTopLevelTests.push([testName, testFn]) + } + } + } + } + + for (const [name, testFn] of allTopLevelTests) { + const should = name.split(/\.?(?=[A-Z])/).join(' ').toLowerCase(); + it(should, testFn.bind(instance)) + } + + for (const [category, tests] of Object.entries(allTests)) { + describe(category, () => { + for (const [name, testFn] of tests) { + const should = name.split(/\.?(?=[A-Z])/).join(' ').toLowerCase(); + it(should, testFn.bind(instance)) + } + }) + } + }) +} + +function runTestSuite() { + for (const ctor of Object.values(testSuite)) { + runTests(ctor as any) + } +} + +runTestSuite(); diff --git a/src/tests/testSuite.ts b/src/tests/testSuite.ts new file mode 100644 index 0000000..f11df41 --- /dev/null +++ b/src/tests/testSuite.ts @@ -0,0 +1 @@ +export { default as ComfyPromptSerializerTests } from "./ComfyPromptSerializerTests" From 0c74b3af59aa5d6c52c656e692fe5117853682fd Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Tue, 16 May 2023 11:02:03 -0500 Subject: [PATCH 05/38] Graphvis debug view --- package.json | 2 +- pnpm-lock.yaml | 140 +++++++++++++++++--- src/lib/components/ComfyPromptSerializer.ts | 40 +++--- src/lib/utils.ts | 101 +++++++++++++- src/tests/ComfyPromptSerializerTests.ts | 19 ++- vite.config.ts | 2 +- 6 files changed, 262 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index f1e98d3..7b9d40a 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "vite-plugin-static-copy": "^0.14.0", "vite-plugin-svelte-console-remover": "^1.0.10", "vite-tsconfig-paths": "^4.0.8", - "vitest": "^0.25.8" + "vitest": "^0.27.3" }, "type": "module", "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccc617f..50d3a51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,8 +183,8 @@ importers: specifier: ^4.0.8 version: 4.0.8(typescript@5.0.3)(vite@4.3.1) vitest: - specifier: ^0.25.8 - version: 0.25.8(happy-dom@9.18.3)(jsdom@22.0.0)(sass@1.61.0) + specifier: ^0.27.3 + version: 0.27.3(happy-dom@9.18.3)(jsdom@22.0.0)(sass@1.61.0) gradio/client/js: dependencies: @@ -780,7 +780,7 @@ importers: version: 4.10.1(postcss-load-config@3.1.4)(postcss@8.4.21)(svelte@3.58.0)(typescript@4.5.4) tailwindcss: specifier: ^3.0.12 - version: 3.3.1 + version: 3.3.1(postcss@8.4.21) tslib: specifier: ^2.3.1 version: 2.5.0 @@ -3725,7 +3725,6 @@ packages: /cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - dev: false /call-bind@1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} @@ -6321,7 +6320,6 @@ packages: /jsonc-parser@3.2.0: resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} - dev: false /jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -6721,7 +6719,6 @@ packages: pathe: 1.1.0 pkg-types: 1.0.3 ufo: 1.1.2 - dev: false /mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -7047,9 +7044,12 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + /pathe@0.2.0: + resolution: {integrity: sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==} + dev: true + /pathe@1.1.0: resolution: {integrity: sha512-ODbEPR0KKHqECXW1GoxdDb+AZvULmXjVPy4rt+pGo2+TnjJTIPJQSVS6N63n8T2Ip+syHhbn52OewKicV0373w==} - dev: false /pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} @@ -7086,7 +7086,6 @@ packages: jsonc-parser: 3.2.0 mlly: 1.2.1 pathe: 1.1.0 - dev: false /plotly.js-dist-min@2.10.1: resolution: {integrity: sha512-H0ls1C2uu2U+qWw76djo4/zOGtUKfMILwFhu7tCOaG/wH5ypujrYGCH03N9SQVf1SXcctTfW57USf8LmagSiPQ==} @@ -7106,6 +7105,17 @@ packages: prettier: 2.8.7 dev: false + /postcss-import@14.1.0: + resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==} + engines: {node: '>=10.0.0'} + peerDependencies: + postcss: ^8.0.0 + dependencies: + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.2 + dev: false + /postcss-import@14.1.0(postcss@8.4.21): resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==} engines: {node: '>=10.0.0'} @@ -7116,6 +7126,16 @@ packages: postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.2 + dev: true + + /postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + dependencies: + camelcase-css: 2.0.1 + dev: false /postcss-js@4.0.1(postcss@8.4.21): resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} @@ -7125,6 +7145,23 @@ packages: dependencies: camelcase-css: 2.0.1 postcss: 8.4.21 + dev: true + + /postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + dev: false /postcss-load-config@3.1.4(postcss@8.4.21): resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} @@ -7141,6 +7178,16 @@ packages: lilconfig: 2.1.0 postcss: 8.4.21 yaml: 1.10.2 + dev: true + + /postcss-nested@6.0.0: + resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + dependencies: + postcss-selector-parser: 6.0.11 + dev: false /postcss-nested@6.0.0(postcss@8.4.21): resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==} @@ -7150,6 +7197,7 @@ packages: dependencies: postcss: 8.4.21 postcss-selector-parser: 6.0.11 + dev: true /postcss-prefix-selector@1.16.0(postcss@8.4.21): resolution: {integrity: sha512-rdVMIi7Q4B0XbXqNUEI+Z4E+pueiu/CS5E6vRCQommzdQ/sgsS4dK42U7GX8oJR+TJOtT+Qv3GkNo6iijUMp3Q==} @@ -7627,7 +7675,6 @@ packages: /siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - dev: false /sigmund@1.0.1: resolution: {integrity: sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==} @@ -7694,7 +7741,6 @@ packages: dependencies: buffer-from: 1.1.2 source-map: 0.6.1 - dev: false /source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} @@ -7752,7 +7798,6 @@ packages: /stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - dev: false /standardized-audio-context@25.3.45: resolution: {integrity: sha512-d1UVvbz0mDmEqNehvoTKlpSevRJ3YiVZ6kdboeaWX+8cl94H1w8x7c5RNdg0nqxiE049LMeF4tFPuDl5Vm78Kg==} @@ -7764,7 +7809,6 @@ packages: /std-env@3.3.3: resolution: {integrity: sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==} - dev: false /streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} @@ -8205,6 +8249,43 @@ packages: resolution: {integrity: sha512-Vkiouc41d4CEq0ujXl6oiGFQ7bA3WEhUZdTgXAhtKxSy49OmKs8rEfQmupsfF0IGW8fv2iQkp1EVUuapCFrZ9g==} engines: {node: '>=12.13.0'} hasBin: true + peerDependencies: + postcss: ^8.0.9 + dependencies: + arg: 5.0.2 + chokidar: 3.5.3 + color-name: 1.1.4 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.2.12 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.18.2 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.21 + postcss-import: 14.1.0 + postcss-js: 4.0.1 + postcss-load-config: 3.1.4 + postcss-nested: 6.0.0 + postcss-selector-parser: 6.0.11 + postcss-value-parser: 4.2.0 + quick-lru: 5.1.1 + resolve: 1.22.2 + sucrase: 3.32.0 + transitivePeerDependencies: + - ts-node + dev: false + + /tailwindcss@3.3.1(postcss@8.4.21): + resolution: {integrity: sha512-Vkiouc41d4CEq0ujXl6oiGFQ7bA3WEhUZdTgXAhtKxSy49OmKs8rEfQmupsfF0IGW8fv2iQkp1EVUuapCFrZ9g==} + engines: {node: '>=12.13.0'} + hasBin: true + peerDependencies: + postcss: ^8.0.9 dependencies: arg: 5.0.2 chokidar: 3.5.3 @@ -8232,6 +8313,7 @@ packages: sucrase: 3.32.0 transitivePeerDependencies: - ts-node + dev: true /term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -8488,7 +8570,6 @@ packages: /ufo@1.1.2: resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==} - dev: false /undici@5.20.0: resolution: {integrity: sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g==} @@ -8969,6 +9050,29 @@ packages: extsprintf: 1.3.0 dev: false + /vite-node@0.27.3(@types/node@18.16.0)(sass@1.61.0): + resolution: {integrity: sha512-eyJYOO64o5HIp8poc4bJX+ZNBwMZeI3f6/JdiUmJgW02Mt7LnoCtDMRVmLaY9S05SIsjGe339ZK4uo2wQ+bF9g==} + engines: {node: '>=v14.16.0'} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.4 + mlly: 1.2.1 + pathe: 0.2.0 + picocolors: 1.0.0 + source-map: 0.6.1 + source-map-support: 0.5.21 + vite: 4.3.1(@types/node@18.16.0)(sass@1.61.0) + transitivePeerDependencies: + - '@types/node' + - less + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vite-node@0.31.0(@types/node@18.16.0): resolution: {integrity: sha512-8x1x1LNuPvE2vIvkSB7c1mApX5oqlgsxzHQesYF7l5n1gKrEmrClIiZuOFbFDQcjLsmcWSwwmrWrcGWm9Fxc/g==} engines: {node: '>=v14.18.0'} @@ -9362,8 +9466,8 @@ packages: vite: 4.3.1(sass@1.61.0) dev: false - /vitest@0.25.8(happy-dom@9.18.3)(jsdom@22.0.0)(sass@1.61.0): - resolution: {integrity: sha512-X75TApG2wZTJn299E/TIYevr4E9/nBo1sUtZzn0Ci5oK8qnpZAZyhwg0qCeMSakGIWtc6oRwcQFyFfW14aOFWg==} + /vitest@0.27.3(happy-dom@9.18.3)(jsdom@22.0.0)(sass@1.61.0): + resolution: {integrity: sha512-Ld3UVgRVhJUtqvQ3dW89GxiApFAgBsWJZBCWzK+gA3w2yG68csXlGZZ4WDJURf+8ecNfgrScga6xY+8YSOpiMg==} engines: {node: '>=v14.16.0'} hasBin: true peerDependencies: @@ -9389,17 +9493,22 @@ packages: '@types/node': 18.16.0 acorn: 8.8.2 acorn-walk: 8.2.0 + cac: 6.7.14 chai: 4.3.7 debug: 4.3.4 happy-dom: 9.18.3 jsdom: 22.0.0 local-pkg: 0.4.3 + picocolors: 1.0.0 source-map: 0.6.1 + std-env: 3.3.3 strip-literal: 1.0.1 tinybench: 2.4.0 tinypool: 0.3.1 tinyspy: 1.1.1 vite: 4.3.1(@types/node@18.16.0)(sass@1.61.0) + vite-node: 0.27.3(@types/node@18.16.0)(sass@1.61.0) + why-is-node-running: 2.2.2 transitivePeerDependencies: - less - sass @@ -9591,7 +9700,6 @@ packages: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - dev: false /word-wrap@1.2.3: resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} diff --git a/src/lib/components/ComfyPromptSerializer.ts b/src/lib/components/ComfyPromptSerializer.ts index d416a64..e0d038a 100644 --- a/src/lib/components/ComfyPromptSerializer.ts +++ b/src/lib/components/ComfyPromptSerializer.ts @@ -54,6 +54,23 @@ function followGraphInput(graphInput: GraphInput, link: LLink): LLink | null { return nextLink; } +function getUpstreamLink(parent: LGraphNode, currentLink: LLink): LLink | null { + if (parent.is(Subgraph)) { + console.warn("FollowSubgraph") + return followSubgraph(parent, currentLink); + } + else if (parent.is(GraphInput)) { + console.warn("FollowGraphInput") + + return followGraphInput(parent, currentLink); + } + else if ("getUpstreamLink" in parent) { + return (parent as ComfyGraphNode).getUpstreamLink(); + } + console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type) + return null; +} + export default class ComfyPromptSerializer { serializeInputValues(node: ComfyBackendNode): Record { // Store input values passed by frontend-only nodes @@ -118,7 +135,7 @@ export default class ComfyPromptSerializer { let parent: ComfyGraphNode = node.getInputNode(i) as ComfyGraphNode; if (parent) { const seen = {} - let link = node.getInputLink(i); + let currentLink = node.getInputLink(i); const isFrontendParent = (parent: ComfyGraphNode) => { if (!parent || parent.isBackendNode) @@ -135,20 +152,7 @@ export default class ComfyPromptSerializer { // nodes have conditional logic that determines which link // to follow backwards. while (isFrontendParent(parent)) { - let nextLink = null; - if (parent.is(Subgraph)) { - nextLink = followSubgraph(parent, link); - } - else if (parent.is(GraphInput)) { - nextLink = followGraphInput(parent, link); - } - else if ("getUpstreamLink" in parent) { - nextLink = parent.getUpstreamLink(); - } - else { - console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type) - break; - } + const nextLink = getUpstreamLink(parent, currentLink); if (nextLink == null) { console.warn("[graphToPrompt] No upstream link found in frontend node", parent) @@ -164,7 +168,7 @@ export default class ComfyPromptSerializer { } else { console.debug("[graphToPrompt] Traverse upstream link", parent.id, inputNode?.id, inputNode?.isBackendNode) - link = nextLink; + currentLink = nextLink; parent = inputNode; } } else { @@ -172,7 +176,7 @@ export default class ComfyPromptSerializer { } } - if (link && parent && parent.isBackendNode) { + if (currentLink && parent && parent.isBackendNode) { if (tag && !hasTag(parent, tag)) continue; @@ -181,7 +185,7 @@ export default class ComfyPromptSerializer { // TODO can null be a legitimate value in some cases? // Nodes like CLIPLoader will never have a value in the frontend, hence "null". if (!(input.name in inputs)) - inputs[input.name] = [String(link.origin_id), link.origin_slot]; + inputs[input.name] = [String(currentLink.origin_id), currentLink.origin_slot]; } } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9c0cdc1..31b5c23 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,7 +5,7 @@ import TextWidget from "$lib/widgets/TextWidget.svelte"; import { get } from "svelte/store" import layoutState from "$lib/stores/layoutState" import type { SvelteComponentDev } from "svelte/internal"; -import type { SerializedLGraph } from "@litegraph-ts/core"; +import { Subgraph, type LGraph, type LGraphNode, type LLink, type SerializedLGraph, type UUID, GraphInput } from "@litegraph-ts/core"; import type { FileNameOrGalleryData, ComfyExecutionResult, ComfyImageLocation } from "./nodes/ComfyWidgetNodes"; import type { FileData as GradioFileData } from "@gradio/upload"; @@ -21,6 +21,13 @@ export function range(size: number, startAt: number = 0): ReadonlyArray return [...Array(size).keys()].map(i => i + startAt); } +export function* enumerate(iterable: Iterable): Iterable<[number, T]> { + let index = 0; + for (const value of iterable) { + yield [index++, value]; + } +} + export function download(filename: string, text: string, type: string = "text/plain") { const blob = new Blob([text], { type: type }); const url = URL.createObjectURL(blob); @@ -68,6 +75,98 @@ export function startDrag(evt: MouseEvent) { export function stopDrag(evt: MouseEvent) { }; +export function graphToGraphVis(graph: LGraph): string { + let links: string[] = [] + let seenLinks = new Set() + let subgraphs: Record = {} + let subgraphNodes: Record = {} + let idToInt: Record = {} + let curId = 0; + + const convId = (id: number | UUID): number => { + if (idToInt[id] == null) { + idToInt[id] = curId++; + } + return idToInt[id]; + } + + const addLink = (node: LGraphNode, link: LLink): string => { + const nodeA = node.graph.getNodeById(link.origin_id) + const nodeB = node.graph.getNodeById(link.target_id); + seenLinks.add(link.id) + return ` "${convId(nodeA.id)}_${nodeA.title}" -> "${convId(nodeB.id)}_${nodeB.title}";\n`; + } + + for (const node of graph.iterateNodesInOrderRecursive()) { + for (let [index, input] of enumerate(node.iterateInputInfo())) { + const link = node.getInputLink(index); + if (link && !seenLinks.has(link.id)) { + const linkText = addLink(node, link) + if (node.graph != graph) { + subgraphs[node.graph._subgraph_node.id] ||= [node.graph._subgraph_node, []] + subgraphs[node.graph._subgraph_node.id][1].push(linkText) + subgraphNodes[node.graph._subgraph_node.id] = node.graph._subgraph_node + } + else { + links.push(linkText) + } + } + } + for (let [index, output] of enumerate(node.iterateOutputInfo())) { + for (const link of node.getOutputLinks(index)) { + if (!seenLinks.has(link.id)) { + const linkText = addLink(node, link) + if (node.graph != graph) { + subgraphs[node.graph._subgraph_node.id] ||= [node.graph._subgraph_node, []] + subgraphs[node.graph._subgraph_node.id][1].push(linkText) + subgraphNodes[node.graph._subgraph_node.id] = node.graph._subgraph_node + } + else if (!node.is(Subgraph) && !node.graph.getNodeById(link.target_id)?.is(Subgraph)) { + links.push(linkText) + } + } + } + } + } + + let out = "digraph {\n" + out += " node [shape=box];\n" + + for (const [subgraph, links] of Object.values(subgraphs)) { + // Subgraph name has to be prefixed with "cluster" to show up as a cluster... + out += ` subgraph cluster_subgraph_${convId(subgraph.id)} {\n` + out += ` label="${convId(subgraph.id)}: ${subgraph.title}";\n`; + out += " color=red;\n"; + // out += " style=grey;\n"; + out += " node [style=filled,fillcolor=white];\n"; + out += " " + links.join(" ") + out += " }\n" + } + + out += links.join("") + + for (const subgraphNode of Object.values(subgraphNodes)) { + for (const [index, input] of enumerate(subgraphNode.iterateInputInfo())) { + const link = subgraphNode.getInputLink(index); + if (link) { + const inputNode = subgraphNode.getInputNode(link.origin_slot); + const innerInput = subgraphNode.getInnerGraphInputByIndex(index); + out += ` "${convId(link.origin_id)}_${inputNode.title}" -> "${convId(innerInput.id)}_${innerInput.title}";\n` + } + } + for (const [index, output] of enumerate(subgraphNode.iterateOutputInfo())) { + for (const link of subgraphNode.getOutputLinks(index)) { + const outputNode = subgraphNode.graph.getNodeById(link.target_id) + const innerOutput = subgraphNode.getInnerGraphOutputByIndex(index); + out += ` "${convId(innerOutput.id)}_${innerOutput.title}" -> "${convId(link.origin_id)}_${outputNode.title}";\n` + } + } + } + + out += "}" + return out +} + export function workflowToGraphVis(workflow: SerializedLGraph): string { let out = "digraph {\n" diff --git a/src/tests/ComfyPromptSerializerTests.ts b/src/tests/ComfyPromptSerializerTests.ts index 44723c9..9bce926 100644 --- a/src/tests/ComfyPromptSerializerTests.ts +++ b/src/tests/ComfyPromptSerializerTests.ts @@ -6,6 +6,7 @@ import ComfyGraph from "$lib/ComfyGraph"; import ComfyPromptSerializer from "$lib/components/ComfyPromptSerializer"; import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; import ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; +import { graphToGraphVis } from "$lib/utils"; class MockBackendInput extends ComfyGraphNode { override isBackendNode = true; @@ -97,9 +98,12 @@ export default class ComfyPromptSerializerTests extends UnitTest { const result = ser.serialize(graph) + console.warn(result.output) expect(Object.keys(result.output)).toHaveLength(3); - expect(Object.keys(result.output[input.id].inputs)).toHaveLength(1); - expect(Object.keys(result.output[link.id].inputs)).toHaveLength(1); + expect(result.output[input.id].inputs["in"]).toBeInstanceOf(Array) + expect(result.output[input.id].inputs["in"][0]).toEqual(link.id) + expect(result.output[link.id].inputs["in"]).toBeInstanceOf(Array) + expect(result.output[link.id].inputs["in"][0]).toEqual(output.id) expect(Object.keys(result.output[output.id].inputs)).toHaveLength(0); } @@ -115,6 +119,7 @@ export default class ComfyPromptSerializerTests extends UnitTest { const graphInput = subgraph.addGraphInput("testIn", "number") const graphOutput = subgraph.addGraphOutput("testOut", "number") + graph.add(subgraph) graph.add(output) subgraph.subgraph.add(link) graph.add(input) @@ -126,9 +131,13 @@ export default class ComfyPromptSerializerTests extends UnitTest { const result = ser.serialize(graph) + console.warn(graphToGraphVis(graph)) + console.warn(result.output) expect(Object.keys(result.output)).toHaveLength(3); - expect(Object.keys(result.output[input.id].inputs)).toHaveLength(1); - expect(Object.keys(result.output[link.id].inputs)).toHaveLength(1); - expect(Object.keys(result.output[output.id].inputs)).toHaveLength(0); + expect(result.output[input.id].inputs["in"]).toBeInstanceOf(Array) + expect(result.output[input.id].inputs["in"][0]).toEqual(link.id) + expect(result.output[link.id].inputs["in"]).toBeInstanceOf(Array) + expect(result.output[link.id].inputs["in"][0]).toEqual(output.id) + expect(result.output[output.id].inputs).toEqual({}) } } diff --git a/vite.config.ts b/vite.config.ts index 5938dbd..9e6586f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -74,7 +74,7 @@ export default defineConfig({ test: { environment: 'jsdom', deps: { - inline: [/^svelte/, /^@floating-ui/, /dist/, "skeleton-elements", "mdn-polyfills"] + inline: [/^svelte/, /^@floating-ui/, /dist/, "skeleton-elements", "mdn-polyfills", "loupe"] }, include: [ 'litegraph/packages/tests/src/main.ts', From 96033e7628dd8e905df5d8c8b3b4900dfd773f53 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Tue, 16 May 2023 11:12:47 -0500 Subject: [PATCH 06/38] Serialize subgraph recursively --- litegraph | 2 +- src/lib/components/ComfyPromptSerializer.ts | 38 +++++++++++---------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/litegraph b/litegraph index 055438a..7fb95e5 160000 --- a/litegraph +++ b/litegraph @@ -1 +1 @@ -Subproject commit 055438a760e68b8efb5ef87a66a7676ca6a6840a +Subproject commit 7fb95e5b83308c3ec8e3f561a31d99cbaec241ba diff --git a/src/lib/components/ComfyPromptSerializer.ts b/src/lib/components/ComfyPromptSerializer.ts index e0d038a..0b1dc27 100644 --- a/src/lib/components/ComfyPromptSerializer.ts +++ b/src/lib/components/ComfyPromptSerializer.ts @@ -1,7 +1,7 @@ import type ComfyGraph from "$lib/ComfyGraph"; import type { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; -import { GraphInput, LGraphNode, LLink, NodeMode, Subgraph } from "@litegraph-ts/core"; +import { GraphInput, GraphOutput, LGraph, LGraphNode, LLink, NodeMode, Subgraph } from "@litegraph-ts/core"; import type { SerializedPrompt, SerializedPromptInput, SerializedPromptInputs, SerializedPromptInputsAll } from "./ComfyApp"; import type IComfyInputSlot from "$lib/IComfyInputSlot"; @@ -26,7 +26,7 @@ function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): node is return true; } -function followSubgraph(subgraph: Subgraph, link: LLink): LLink | null { +function followSubgraph(subgraph: Subgraph, link: LLink): [LGraph | null, LLink | null] { if (link.origin_id != subgraph.id) throw new Error("A!") @@ -35,10 +35,10 @@ function followSubgraph(subgraph: Subgraph, link: LLink): LLink | null { throw new Error("No inner graph input!") const nextLink = innerGraphOutput.getInputLink(0) - return nextLink; + return [innerGraphOutput.graph, nextLink]; } -function followGraphInput(graphInput: GraphInput, link: LLink): LLink | null { +function followGraphInput(graphInput: GraphInput, link: LLink): [LGraph | null, LLink | null] { if (link.origin_id != graphInput.id) throw new Error("A!") @@ -51,24 +51,23 @@ function followGraphInput(graphInput: GraphInput, link: LLink): LLink | null { throw new Error("No outer input slot!") const nextLink = outerSubgraph.getInputLink(outerInputIndex) - return nextLink; + return [outerSubgraph.graph, nextLink]; } -function getUpstreamLink(parent: LGraphNode, currentLink: LLink): LLink | null { +function getUpstreamLink(parent: LGraphNode, currentLink: LLink): [LGraph | null, LLink | null] { if (parent.is(Subgraph)) { console.warn("FollowSubgraph") return followSubgraph(parent, currentLink); } else if (parent.is(GraphInput)) { console.warn("FollowGraphInput") - return followGraphInput(parent, currentLink); } else if ("getUpstreamLink" in parent) { - return (parent as ComfyGraphNode).getUpstreamLink(); + return [parent.graph, (parent as ComfyGraphNode).getUpstreamLink()]; } console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type) - return null; + return [null, null]; } export default class ComfyPromptSerializer { @@ -132,13 +131,13 @@ export default class ComfyPromptSerializer { // Store links between backend-only and hybrid nodes for (let i = 0; i < node.inputs.length; i++) { - let parent: ComfyGraphNode = node.getInputNode(i) as ComfyGraphNode; + let parent = node.getInputNode(i); if (parent) { const seen = {} let currentLink = node.getInputLink(i); - const isFrontendParent = (parent: ComfyGraphNode) => { - if (!parent || parent.isBackendNode) + const isFrontendParent = (parent: LGraphNode) => { + if (!parent || (parent as any).isBackendNode) return false; if (tag && !hasTag(parent, tag)) return false; @@ -152,7 +151,7 @@ export default class ComfyPromptSerializer { // nodes have conditional logic that determines which link // to follow backwards. while (isFrontendParent(parent)) { - const nextLink = getUpstreamLink(parent, currentLink); + const [nextGraph, nextLink] = getUpstreamLink(parent, currentLink); if (nextLink == null) { console.warn("[graphToPrompt] No upstream link found in frontend node", parent) @@ -161,22 +160,22 @@ export default class ComfyPromptSerializer { if (nextLink && !seen[nextLink.id]) { seen[nextLink.id] = true - const inputNode = parent.graph.getNodeById(nextLink.origin_id) as ComfyGraphNode; - if (inputNode && tag && !hasTag(inputNode, tag)) { + const nextParent = nextGraph.getNodeById(nextLink.origin_id); + if (nextParent && tag && !hasTag(nextParent, tag)) { console.debug("[graphToPrompt] Skipping tagged intermediate frontend node", tag, node.properties.tags) parent = null; } else { - console.debug("[graphToPrompt] Traverse upstream link", parent.id, inputNode?.id, inputNode?.isBackendNode) + console.debug("[graphToPrompt] Traverse upstream link", parent.id, nextParent?.id, nextParent?.isBackendNode) currentLink = nextLink; - parent = inputNode; + parent = nextParent; } } else { parent = null; } } - if (currentLink && parent && parent.isBackendNode) { + if (currentLink && parent && (parent as any).isBackendNode) { if (tag && !hasTag(parent, tag)) continue; @@ -187,6 +186,9 @@ export default class ComfyPromptSerializer { if (!(input.name in inputs)) inputs[input.name] = [String(currentLink.origin_id), currentLink.origin_slot]; } + else { + console.warn("[graphToPrompt] Didn't find upstream link!", currentLink, parent?.id) + } } } From 979f6eaeedf9a8934522077ee8e430c550e5a701 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Tue, 16 May 2023 12:42:49 -0500 Subject: [PATCH 07/38] Work on subgraph support --- litegraph | 2 +- src/lib/ComfyGraph.ts | 6 +- src/lib/api.ts | 12 +-- src/lib/components/ComfyApp.ts | 82 +++++++++++---------- src/lib/components/ComfyPromptSerializer.ts | 2 +- src/lib/nodes/ComfyActionNodes.ts | 1 + src/lib/nodes/ComfyWidgetNodes.ts | 6 +- src/lib/stores/layoutState.ts | 16 ++-- src/lib/stores/queueState.ts | 22 +++--- src/lib/utils.ts | 60 +++++++-------- src/main-desktop.ts | 4 +- src/main-mobile.ts | 8 +- src/tests/ComfyPromptSerializerTests.ts | 38 ++++++++++ 13 files changed, 145 insertions(+), 114 deletions(-) diff --git a/litegraph b/litegraph index 7fb95e5..edc0ccb 160000 --- a/litegraph +++ b/litegraph @@ -1 +1 @@ -Subproject commit 7fb95e5b83308c3ec8e3f561a31d99cbaec241ba +Subproject commit edc0ccbb086cf44db847e48f371b2793a19b1340 diff --git a/src/lib/ComfyGraph.ts b/src/lib/ComfyGraph.ts index 63cd1a9..3022664 100644 --- a/src/lib/ComfyGraph.ts +++ b/src/lib/ComfyGraph.ts @@ -26,17 +26,14 @@ export default class ComfyGraph extends LGraph { override onConfigure() { console.debug("Configured"); - this.eventBus.emit("configured", this); } override onBeforeChange(graph: LGraph, info: any) { console.debug("BeforeChange", info); - this.eventBus.emit("beforeChange", graph, info); } override onAfterChange(graph: LGraph, info: any) { console.debug("AfterChange", info); - this.eventBus.emit("afterChange", graph, info); } override onAfterExecute() { @@ -44,6 +41,9 @@ export default class ComfyGraph extends LGraph { } override onNodeAdded(node: LGraphNode, options: LGraphAddNodeOptions) { + if (options.subgraphs && options.subgraphs.length > 0) + return + layoutState.nodeAdded(node, options) // All nodes whether they come from base litegraph or ComfyBox should diff --git a/src/lib/api.ts b/src/lib/api.ts index c457de4..aaf256a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Progress, SerializedPrompt, SerializedPromptInputs, SerializedPromptInputsAll, SerializedPromptOutput, SerializedPromptOutputs } from "./components/ComfyApp"; +import type { Progress, SerializedPrompt, SerializedPromptInputs, SerializedPromptInputsAll, SerializedPromptOutputs } from "./components/ComfyApp"; import type TypedEmitter from "typed-emitter"; import EventEmitter from "events"; import type { ComfyExecutionResult, ComfyImageLocation } from "./nodes/ComfyWidgetNodes"; @@ -30,7 +30,7 @@ export type ComfyAPIQueueResponse = { error?: string } -export type NodeID = UUID; +export type ComfyNodeID = UUID; // To distinguish from Litegraph NodeID export type PromptID = UUID; // UUID export type ComfyAPIHistoryItem = [ @@ -38,7 +38,7 @@ export type ComfyAPIHistoryItem = [ PromptID, SerializedPromptInputsAll, ComfyBoxPromptExtraData, - NodeID[] // good outputs + ComfyNodeID[] // good outputs ] export type ComfyAPIPromptResponse = { @@ -76,9 +76,9 @@ type ComfyAPIEvents = { progress: (progress: Progress) => void, reconnecting: () => void, reconnected: () => void, - executing: (promptID: PromptID | null, runningNodeID: NodeID | null) => void, - executed: (promptID: PromptID, nodeID: NodeID, output: SerializedPromptOutput) => void, - execution_cached: (promptID: PromptID, nodes: NodeID[]) => void, + executing: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => void, + executed: (promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutputs) => void, + execution_cached: (promptID: PromptID, nodes: ComfyNodeID[]) => void, execution_error: (promptID: PromptID, message: string) => void, } diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index 821ef64..fa094b3 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -1,6 +1,6 @@ -import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot } from "@litegraph-ts/core"; +import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID } from "@litegraph-ts/core"; import type { LConnectionKind, INodeSlot } from "@litegraph-ts/core"; -import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type NodeID, type PromptID } from "$lib/api" +import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type ComfyNodeID, type PromptID } from "$lib/api" import { getPngMetadata, importA1111 } from "$lib/pnginfo"; import EventEmitter from "events"; import type TypedEmitter from "typed-emitter"; @@ -28,7 +28,7 @@ import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; import { get } from "svelte/store"; import { tick } from "svelte"; import uiState from "$lib/stores/uiState"; -import { download, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis } from "$lib/utils"; +import { download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis } from "$lib/utils"; import notify from "$lib/notify"; import configState from "$lib/stores/configState"; import { blankGraph } from "$lib/defaultGraph"; @@ -57,7 +57,7 @@ export type SerializedAppState = { } /** [link origin, link index] | value */ -export type SerializedPromptInput = [NodeID, number] | any +export type SerializedPromptInput = [ComfyNodeID, number] | any export type SerializedPromptInputs = { /* property name -> value or link */ @@ -65,14 +65,14 @@ export type SerializedPromptInputs = { class_type: string } -export type SerializedPromptInputsAll = Record +export type SerializedPromptInputsAll = Record export type SerializedPrompt = { workflow: SerializedLGraph, output: SerializedPromptInputsAll } -export type SerializedPromptOutputs = Record +export type SerializedPromptOutputs = Record export type Progress = { value: number, @@ -324,21 +324,21 @@ export default class ComfyApp { this.lGraph.setDirtyCanvas(true, false); }); - this.api.addEventListener("executing", (promptID: PromptID | null, nodeID: NodeID | null) => { + this.api.addEventListener("executing", (promptID: PromptID | null, nodeID: ComfyNodeID | null) => { queueState.executingUpdated(promptID, nodeID); this.lGraph.setDirtyCanvas(true, false); }); - this.api.addEventListener("executed", (promptID: PromptID, nodeID: NodeID, output: ComfyExecutionResult) => { + this.api.addEventListener("executed", (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => { this.nodeOutputs[nodeID] = output; - const node = this.lGraph.getNodeById(nodeID) as ComfyGraphNode; + const node = this.lGraph.getNodeByIdRecursive(nodeID) as ComfyGraphNode; if (node?.onExecuted) { node.onExecuted(output); } queueState.onExecuted(promptID, nodeID, output) }); - this.api.addEventListener("execution_cached", (promptID: PromptID, nodes: NodeID[]) => { + this.api.addEventListener("execution_cached", (promptID: PromptID, nodes: ComfyNodeID[]) => { queueState.executionCached(promptID, nodes) }); @@ -490,6 +490,7 @@ export default class ComfyApp { } layoutState.onStartConfigure(); + this.lCanvas.closeAllSubgraphs(); this.lGraph.configure(blankGraph) layoutState.initDefaultLayout(); uiState.update(s => { @@ -588,6 +589,7 @@ export default class ComfyApp { const p = this.graphToPrompt(tag); const l = layoutState.serialize(); + console.debug(graphToGraphVis(this.lGraph)) console.debug(promptToGraphVis(p)) const extraData: ComfyBoxPromptExtraData = { @@ -619,19 +621,20 @@ export default class ComfyApp { error = response.error; } catch (err) { - error = err + error = { error: err } } if (error != null) { - const mes = error.response || error.toString() + const mes = error.error notify(`Error queuing prompt:\n${mes}`, { type: "error" }) + console.error(graphToGraphVis(this.lGraph)) console.error(promptToGraphVis(p)) console.error("Error queuing prompt", error, num, p) break; } for (const n of p.workflow.nodes) { - const node = this.lGraph.getNodeById(n.id); + const node = this.lGraph.getNodeByIdRecursive(n.id); if ("afterQueued" in node) { (node as ComfyGraphNode).afterQueued(p, tag); } @@ -689,8 +692,6 @@ export default class ComfyApp { async refreshComboInNodes(flashUI: boolean = false) { const defs = await this.api.getNodeDefs(); - const toUpdate: BackendComboNode[] = [] - const isComfyComboNode = (node: LGraphNode): boolean => { return node && node.type === "ui/combo" @@ -704,14 +705,14 @@ export default class ComfyApp { } // Node IDs of combo widgets attached to a backend node - let backendCombos: Set = new Set() + const backendUpdatedCombos: Record = {} console.debug("[refreshComboInNodes] start") // Figure out which combo nodes to update. They need to be connected to // an input slot on a backend node with a backend config in the input // slot connected to. - for (const node of this.lGraph.iterateNodesInOrder()) { + for (const node of this.lGraph.iterateNodesInOrderRecursive()) { if (!(node as any).isBackendNode) continue; @@ -738,40 +739,43 @@ export default class ComfyApp { const hasBackendConfig = def["input"]["required"][inputSlot.name] !== undefined if (hasBackendConfig) { - backendCombos.add(comboNode.id) - toUpdate.push({ comboNode, inputSlot, backendNode }) + backendUpdatedCombos[comboNode.id] = { comboNode, inputSlot, backendNode } } } } - console.debug("[refreshComboInNodes] found:", toUpdate.length, toUpdate) + console.debug("[refreshComboInNodes] found:", backendUpdatedCombos.length, backendUpdatedCombos) // Mark combo nodes without backend configs as being loaded already. - for (const node of this.lGraph.iterateNodesInOrder()) { - if (isComfyComboNode(node) && !backendCombos.has(node.id)) { - const comboNode = node as nodes.ComfyComboNode; - let values = comboNode.properties.values; - - // Frontend nodes can declare defaultWidgets which creates a - // config inside their own inputs slots too. - const foundInput = range(node.outputs.length) - .flatMap(i => node.getInputSlotsConnectedTo(i)) - .find(inp => "config" in inp && Array.isArray((inp.config as any).values)) - - if (foundInput != null) { - const comfyInput = foundInput as IComfyInputSlot; - console.warn("[refreshComboInNodes] found frontend config:", node.title, node.type, comfyInput.config.values) - values = comfyInput.config.values; - } - - comboNode.formatValues(values); + for (const node of this.lGraph.iterateNodesOfClassRecursive(nodes.ComfyComboNode)) { + if (backendUpdatedCombos[node.id] != null) { + continue; } + + // This node isn't connected to a backend node, so it's configured + // by the frontend instead. + const comboNode = node as nodes.ComfyComboNode; + let values = comboNode.properties.values; + + // Frontend nodes can declare defaultWidgets which creates a + // config inside their own inputs slots too. + const foundInput = range(node.outputs.length) + .flatMap(i => node.getInputSlotsConnectedTo(i)) + .find(inp => "config" in inp && Array.isArray((inp.config as any).values)) + + if (foundInput != null) { + const comfyInput = foundInput as IComfyInputSlot; + console.warn("[refreshComboInNodes] found frontend config:", node.title, node.type, comfyInput.config.values) + values = comfyInput.config.values; + } + + comboNode.formatValues(values); } await tick(); // Load definitions from the backend. - for (const { comboNode, inputSlot, backendNode } of toUpdate) { + for (const { comboNode, inputSlot, backendNode } of Object.values(backendUpdatedCombos)) { const def = defs[backendNode.type]; const rawValues = def["input"]["required"][inputSlot.name][0]; diff --git a/src/lib/components/ComfyPromptSerializer.ts b/src/lib/components/ComfyPromptSerializer.ts index 0b1dc27..b112d4f 100644 --- a/src/lib/components/ComfyPromptSerializer.ts +++ b/src/lib/components/ComfyPromptSerializer.ts @@ -166,7 +166,7 @@ export default class ComfyPromptSerializer { parent = null; } else { - console.debug("[graphToPrompt] Traverse upstream link", parent.id, nextParent?.id, nextParent?.isBackendNode) + console.debug("[graphToPrompt] Traverse upstream link", parent.id, nextParent?.id, (nextParent as any)?.isBackendNode) currentLink = nextLink; parent = nextParent; } diff --git a/src/lib/nodes/ComfyActionNodes.ts b/src/lib/nodes/ComfyActionNodes.ts index a34fa81..df614af 100644 --- a/src/lib/nodes/ComfyActionNodes.ts +++ b/src/lib/nodes/ComfyActionNodes.ts @@ -530,6 +530,7 @@ export class ComfySetNodeModeAdvancedAction extends ComfyGraphNode { } for (const [nodeId, newMode] of Object.entries(nodeChanges)) { + // NOTE: Only applies to this subgraph, not parent/child graphs. this.graph.getNodeById(nodeId).changeMode(newMode); } diff --git a/src/lib/nodes/ComfyWidgetNodes.ts b/src/lib/nodes/ComfyWidgetNodes.ts index 0185bdc..e1b559a 100644 --- a/src/lib/nodes/ComfyWidgetNodes.ts +++ b/src/lib/nodes/ComfyWidgetNodes.ts @@ -18,7 +18,7 @@ import CheckboxWidget from "$lib/widgets/CheckboxWidget.svelte"; import RadioWidget from "$lib/widgets/RadioWidget.svelte"; import ImageUploadWidget from "$lib/widgets/ImageUploadWidget.svelte"; import ImageEditorWidget from "$lib/widgets/ImageEditorWidget.svelte"; -import type { NodeID } from "$lib/api"; +import type { ComfyNodeID } from "$lib/api"; export type AutoConfigOptions = { includeProperties?: Set | null, @@ -272,7 +272,7 @@ export abstract class ComfyWidgetNode extends ComfyGraphNode { } if (options.setWidgetTitle) { - const widget = layoutState.findLayoutForNode(this.id as NodeID) + const widget = layoutState.findLayoutForNode(this.id as ComfyNodeID) if (widget && input.name !== "") { widget.attrs.title = input.name; } @@ -291,7 +291,7 @@ export abstract class ComfyWidgetNode extends ComfyGraphNode { } notifyPropsChanged() { - const layoutEntry = layoutState.findLayoutEntryForNode(this.id as NodeID) + const layoutEntry = layoutState.findLayoutEntryForNode(this.id as ComfyNodeID) if (layoutEntry && layoutEntry.parent) { layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1) } diff --git a/src/lib/stores/layoutState.ts b/src/lib/stores/layoutState.ts index b65d848..0c0f7de 100644 --- a/src/lib/stores/layoutState.ts +++ b/src/lib/stores/layoutState.ts @@ -4,7 +4,7 @@ import type ComfyApp from "$lib/components/ComfyApp" import { type LGraphNode, type IWidget, type LGraph, NodeMode, type LGraphRemoveNodeOptions, type LGraphAddNodeOptions, type UUID } from "@litegraph-ts/core" import { SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action'; import type { ComfyWidgetNode } from '$lib/nodes'; -import type { NodeID } from '$lib/api'; +import type { ComfyNodeID } from '$lib/api'; import { v4 as uuidv4 } from "uuid"; type DragItemEntry = { @@ -60,7 +60,7 @@ export type LayoutState = { * Items indexed by the litegraph node they're bound to * Only contains drag items of type "widget" */ - allItemsByNode: Record, + allItemsByNode: Record, /* * Selected drag items. @@ -663,8 +663,8 @@ type LayoutStateOps = { groupItems: (dragItems: IDragItem[], attrs?: Partial) => ContainerLayout, ungroup: (container: ContainerLayout) => void, getCurrentSelection: () => IDragItem[], - findLayoutEntryForNode: (nodeId: NodeID) => DragItemEntry | null, - findLayoutForNode: (nodeId: NodeID) => IDragItem | null, + findLayoutEntryForNode: (nodeId: ComfyNodeID) => DragItemEntry | null, + findLayoutForNode: (nodeId: ComfyNodeID) => IDragItem | null, serialize: () => SerializedLayoutState, deserialize: (data: SerializedLayoutState, graph: LGraph) => void, initDefaultLayout: () => void, @@ -924,7 +924,7 @@ function ungroup(container: ContainerLayout) { store.set(state) } -function findLayoutEntryForNode(nodeId: NodeID): DragItemEntry | null { +function findLayoutEntryForNode(nodeId: ComfyNodeID): DragItemEntry | null { const state = get(store) const found = Object.entries(state.allItems).find(pair => pair[1].dragItem.type === "widget" @@ -934,7 +934,7 @@ function findLayoutEntryForNode(nodeId: NodeID): DragItemEntry | null { return null; } -function findLayoutForNode(nodeId: NodeID): WidgetLayout | null { +function findLayoutForNode(nodeId: ComfyNodeID): WidgetLayout | null { const found = findLayoutEntryForNode(nodeId); if (!found) return null; @@ -1034,7 +1034,9 @@ function deserialize(data: SerializedLayoutState, graph: LGraph) { if (dragItem.type === "widget") { const widget = dragItem as WidgetLayout; - widget.node = graph.getNodeById(entry.dragItem.nodeId) as ComfyWidgetNode + widget.node = graph.getNodeByIdRecursive(entry.dragItem.nodeId) as ComfyWidgetNode + if (widget.node == null) + throw (`Node in litegraph not found! ${entry.dragItem.nodeId}`) allItemsByNode[entry.dragItem.nodeId] = dragEntry } } diff --git a/src/lib/stores/queueState.ts b/src/lib/stores/queueState.ts index 32a9f4d..339f4b9 100644 --- a/src/lib/stores/queueState.ts +++ b/src/lib/stores/queueState.ts @@ -1,4 +1,4 @@ -import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, NodeID, PromptID } from "$lib/api"; +import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, ComfyNodeID, PromptID } from "$lib/api"; import type { Progress, SerializedPromptInputsAll, SerializedPromptOutputs } from "$lib/components/ComfyApp"; import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes"; import notify from "$lib/notify"; @@ -14,12 +14,12 @@ type QueueStateOps = { queueUpdated: (resp: ComfyAPIQueueResponse) => void, historyUpdated: (resp: ComfyAPIHistoryResponse) => void, statusUpdated: (status: ComfyAPIStatusResponse | null) => void, - executingUpdated: (promptID: PromptID | null, runningNodeID: NodeID | null) => void, - executionCached: (promptID: PromptID, nodes: NodeID[]) => void, + executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => void, + executionCached: (promptID: PromptID, nodes: ComfyNodeID[]) => void, executionError: (promptID: PromptID, message: string) => void, progressUpdated: (progress: Progress) => void afterQueued: (promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void - onExecuted: (promptID: PromptID, nodeID: NodeID, output: ComfyExecutionResult) => void + onExecuted: (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => void } export type QueueEntry = { @@ -30,7 +30,7 @@ export type QueueEntry = { promptID: PromptID, prompt: SerializedPromptInputsAll, extraData: ComfyBoxPromptExtraData, - goodOutputs: NodeID[], + goodOutputs: ComfyNodeID[], /* Data not sent by ComfyUI's API, lost on page refresh */ @@ -38,8 +38,8 @@ export type QueueEntry = { outputs: SerializedPromptOutputs, /* Nodes in of the workflow that have finished running so far. */ - nodesRan: Set, - cachedNodes: Set + nodesRan: Set, + cachedNodes: Set } export type CompletedQueueEntry = { @@ -54,7 +54,7 @@ export type QueueState = { queuePending: Writable, queueCompleted: Writable, queueRemaining: number | "X" | null; - runningNodeID: NodeID | null; + runningNodeID: ComfyNodeID | null; progress: Progress | null, isInterrupting: boolean } @@ -161,7 +161,7 @@ function moveToCompleted(index: number, queue: Writable, status: Q store.set(state) } -function executingUpdated(promptID: PromptID, runningNodeID: NodeID | null) { +function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null) { console.debug("[queueState] executingUpdated", promptID, runningNodeID) store.update((s) => { s.progress = null; @@ -199,7 +199,7 @@ function executingUpdated(promptID: PromptID, runningNodeID: NodeID | null) { }) } -function executionCached(promptID: PromptID, nodes: NodeID[]) { +function executionCached(promptID: PromptID, nodes: ComfyNodeID[]) { console.debug("[queueState] executionCached", promptID, nodes) store.update(s => { const [index, entry, queue] = findEntryInPending(promptID); @@ -257,7 +257,7 @@ function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromp }) } -function onExecuted(promptID: PromptID, nodeID: NodeID, output: ComfyExecutionResult) { +function onExecuted(promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) { console.debug("[queueState] onExecuted", promptID, nodeID, output) store.update(s => { const [index, entry, queue] = findEntryInPending(promptID) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 31b5c23..993292d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -6,8 +6,9 @@ import { get } from "svelte/store" import layoutState from "$lib/stores/layoutState" import type { SvelteComponentDev } from "svelte/internal"; import { Subgraph, type LGraph, type LGraphNode, type LLink, type SerializedLGraph, type UUID, GraphInput } from "@litegraph-ts/core"; -import type { FileNameOrGalleryData, ComfyExecutionResult, ComfyImageLocation } from "./nodes/ComfyWidgetNodes"; +import type { ComfyExecutionResult, ComfyImageLocation } from "./nodes/ComfyWidgetNodes"; import type { FileData as GradioFileData } from "@gradio/upload"; +import type { ComfyNodeID } from "./api"; export function clamp(n: number, min: number, max: number): number { return Math.min(Math.max(n, min), max) @@ -121,7 +122,7 @@ export function graphToGraphVis(graph: LGraph): string { subgraphs[node.graph._subgraph_node.id][1].push(linkText) subgraphNodes[node.graph._subgraph_node.id] = node.graph._subgraph_node } - else if (!node.is(Subgraph) && !node.graph.getNodeById(link.target_id)?.is(Subgraph)) { + else { links.push(linkText) } } @@ -130,39 +131,23 @@ export function graphToGraphVis(graph: LGraph): string { } let out = "digraph {\n" - out += " node [shape=box];\n" + out += ' fontname="Helvetica,Arial,sans-serif"\n' + out += ' node [fontname="Helvetica,Arial,sans-serif"]\n' + out += ' edge [fontname="Helvetica,Arial,sans-serif"]\n' + out += ' node [shape=box style=filled fillcolor="#DDDDDD"]\n' for (const [subgraph, links] of Object.values(subgraphs)) { // Subgraph name has to be prefixed with "cluster" to show up as a cluster... out += ` subgraph cluster_subgraph_${convId(subgraph.id)} {\n` - out += ` label="${convId(subgraph.id)}: ${subgraph.title}";\n`; + out += ` label="${convId(subgraph.id)}_${subgraph.title}";\n`; out += " color=red;\n"; // out += " style=grey;\n"; - out += " node [style=filled,fillcolor=white];\n"; out += " " + links.join(" ") out += " }\n" } out += links.join("") - for (const subgraphNode of Object.values(subgraphNodes)) { - for (const [index, input] of enumerate(subgraphNode.iterateInputInfo())) { - const link = subgraphNode.getInputLink(index); - if (link) { - const inputNode = subgraphNode.getInputNode(link.origin_slot); - const innerInput = subgraphNode.getInnerGraphInputByIndex(index); - out += ` "${convId(link.origin_id)}_${inputNode.title}" -> "${convId(innerInput.id)}_${innerInput.title}";\n` - } - } - for (const [index, output] of enumerate(subgraphNode.iterateOutputInfo())) { - for (const link of subgraphNode.getOutputLinks(index)) { - const outputNode = subgraphNode.graph.getNodeById(link.target_id) - const innerOutput = subgraphNode.getInnerGraphOutputByIndex(index); - out += ` "${convId(innerOutput.id)}_${innerOutput.title}" -> "${convId(link.origin_id)}_${outputNode.title}";\n` - } - } - } - out += "}" return out } @@ -186,17 +171,21 @@ export function promptToGraphVis(prompt: SerializedPrompt): string { for (const pair of Object.entries(prompt.output)) { const [id, o] = pair; const outNode = prompt.workflow.nodes.find(n => n.id == id) - for (const pair2 of Object.entries(o.inputs)) { - const [inpName, i] = pair2; + if (outNode) { + for (const pair2 of Object.entries(o.inputs)) { + const [inpName, i] = pair2; - if (Array.isArray(i) && i.length === 2 && typeof i[0] === "string" && typeof i[1] === "number") { - // Link - const inpNode = prompt.workflow.nodes.find(n => n.id == i[0]) - out += `"${inpNode.title}" -> "${outNode.title}"\n` - } - else { - // Value - out += `"${id}-${inpName}-${i}" -> "${outNode.title}"\n` + if (Array.isArray(i) && i.length === 2 && typeof i[0] === "string" && typeof i[1] === "number") { + // Link + const inpNode = prompt.workflow.nodes.find(n => n.id == i[0]) + if (inpNode) { + out += `"${inpNode.title}" -> "${outNode.title}"\n` + } + } + else { + // Value + out += `"${id}-${inpName}-${i}" -> "${outNode.title}"\n` + } } } } @@ -205,12 +194,13 @@ export function promptToGraphVis(prompt: SerializedPrompt): string { return out } -export function getNodeInfo(nodeId: NodeID): string { +export function getNodeInfo(nodeId: ComfyNodeID): string { let app = (window as any).app; if (!app || !app.lGraph) return String(nodeId); - const title = app.lGraph.getNodeById(nodeId)?.title || String(nodeId); + // TODO subgraph support + const title = app.lGraph.getNodeByIdRecursive(nodeId)?.title || String(nodeId); return title + " (" + nodeId + ")" } diff --git a/src/main-desktop.ts b/src/main-desktop.ts index ea2019f..15f8e17 100644 --- a/src/main-desktop.ts +++ b/src/main-desktop.ts @@ -1,7 +1,7 @@ -import { LiteGraph } from '@litegraph-ts/core'; +import { configureLitegraph } from '$lib/init'; import App from './App.svelte'; -LiteGraph.use_uuids = true; +configureLitegraph() const app = new App({ target: document.getElementById('app'), diff --git a/src/main-mobile.ts b/src/main-mobile.ts index 4f30b66..f306861 100644 --- a/src/main-mobile.ts +++ b/src/main-mobile.ts @@ -6,18 +6,14 @@ import ComfyApp from '$lib/components/ComfyApp'; import uiState from '$lib/stores/uiState'; import { LiteGraph } from '@litegraph-ts/core'; import ComfyGraph from '$lib/ComfyGraph'; +import { configureLitegraph } from '$lib/init'; Framework7.use(Framework7Svelte); -LiteGraph.use_uuids = true; -LiteGraph.dialog_close_on_mouse_leave = false; -LiteGraph.search_hide_on_mouse_leave = false; -LiteGraph.pointerevents_method = "pointer"; +configureLitegraph(true); const comfyApp = new ComfyApp(); -uiState.update(s => { s.app = comfyApp; return s; }) - const app = new AppMobile({ target: document.getElementById('app'), props: { app: comfyApp } diff --git a/src/tests/ComfyPromptSerializerTests.ts b/src/tests/ComfyPromptSerializerTests.ts index 9bce926..946afa1 100644 --- a/src/tests/ComfyPromptSerializerTests.ts +++ b/src/tests/ComfyPromptSerializerTests.ts @@ -131,6 +131,44 @@ export default class ComfyPromptSerializerTests extends UnitTest { const result = ser.serialize(graph) + expect(Object.keys(result.output)).toHaveLength(3); + expect(result.output[input.id].inputs["in"]).toBeInstanceOf(Array) + expect(result.output[input.id].inputs["in"][0]).toEqual(link.id) + expect(result.output[link.id].inputs["in"]).toBeInstanceOf(Array) + expect(result.output[link.id].inputs["in"][0]).toEqual(output.id) + expect(result.output[output.id].inputs).toEqual({}) + } + + test__serialize__shouldFollowSubgraphsRecursively() { + const ser = new ComfyPromptSerializer(); + const graph = new ComfyGraph(); + + const output = LiteGraph.createNode(MockBackendOutput) + const link = LiteGraph.createNode(MockBackendLink) + const input = LiteGraph.createNode(MockBackendInput) + + const subgraphA = LiteGraph.createNode(Subgraph) + const subgraphB = LiteGraph.createNode(Subgraph) + const graphInputA = subgraphA.addGraphInput("testIn", "number") + const graphOutputA = subgraphA.addGraphOutput("testOut", "number") + const graphInputB = subgraphB.addGraphInput("testIn", "number") + const graphOutputB = subgraphB.addGraphOutput("testOut", "number") + + graph.add(subgraphA) + subgraphA.subgraph.add(subgraphB) + graph.add(output) + subgraphB.subgraph.add(link) + graph.add(input) + + output.connect(0, subgraphA, 0) + graphInputA.innerNode.connect(0, subgraphB, 0) + graphInputB.innerNode.connect(0, link, 0) + link.connect(0, graphOutputB.innerNode, 0) + subgraphB.connect(0, graphOutputA.innerNode, 0) + subgraphA.connect(0, input, 0) + + const result = ser.serialize(graph) + console.warn(graphToGraphVis(graph)) console.warn(result.output) expect(Object.keys(result.output)).toHaveLength(3); From 0143b6430ff7132bcc67df04ac97a19a2e14a3af Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Tue, 16 May 2023 17:02:11 -0500 Subject: [PATCH 08/38] Subgraph ergonomic improvements & node def types --- litegraph | 2 +- src/lib/ComfyNodeDef.ts | 52 +++++++++++++++++++++++++++++++ src/lib/api.ts | 3 +- src/lib/components/ComfyApp.ts | 44 +++++++++++++++++--------- src/lib/init.ts | 19 +++++++++++ src/lib/nodes/ComfyBackendNode.ts | 28 +++++++---------- 6 files changed, 115 insertions(+), 33 deletions(-) create mode 100644 src/lib/ComfyNodeDef.ts create mode 100644 src/lib/init.ts diff --git a/litegraph b/litegraph index edc0ccb..b9762fa 160000 --- a/litegraph +++ b/litegraph @@ -1 +1 @@ -Subproject commit edc0ccbb086cf44db847e48f371b2793a19b1340 +Subproject commit b9762fa6d56bf6b1f4f61361b6f9cd2e3972de1d diff --git a/src/lib/ComfyNodeDef.ts b/src/lib/ComfyNodeDef.ts new file mode 100644 index 0000000..d0fb5d0 --- /dev/null +++ b/src/lib/ComfyNodeDef.ts @@ -0,0 +1,52 @@ +import { range } from "./utils" +import ComfyWidgets from "./widgets" + +export type ComfyNodeDef = { + name: string + display_name?: string + category: string + input: ComfyNodeDefInputs + /** Output type like "LATENT" or "IMAGE" */ + output: string[] + output_name: string[] + output_is_list: boolean[] +} + +export type ComfyNodeDefInputs = { + required: Record, + optional?: Record +} +export type ComfyNodeDefInput = [ComfyNodeDefInputType, ComfyNodeDefInputOptions | null] +export type ComfyNodeDefInputType = string[] | keyof typeof ComfyWidgets | string +export type ComfyNodeDefInputOptions = { + forceInput?: boolean +} + +// TODO when comfy refactors +export type ComfyNodeDefOutput = { + type: string, + name: string, + is_list?: boolean +} + +export function isBackendNodeDefInputType(inputName: string, type: ComfyNodeDefInputType): type is string { + return !Array.isArray(type) && !(type in ComfyWidgets) && !(`${type}:${inputName}` in ComfyWidgets); +} + +export function iterateNodeDefInputs(def: ComfyNodeDef): Iterable<[string, ComfyNodeDefInput]> { + var inputs = def.input.required + if (def.input.optional != null) { + inputs = Object.assign({}, def.input.required, def.input.optional) + } + return Object.entries(inputs); +} + +export function iterateNodeDefOutputs(def: ComfyNodeDef): Iterable { + return range(def.output.length).map(i => { + return { + type: def.output[i], + name: def.output_name[i] || def.output[i], + is_list: def.output_is_list[i], + } + }) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index aaf256a..a9c8581 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4,6 +4,7 @@ import EventEmitter from "events"; import type { ComfyExecutionResult, ComfyImageLocation } from "./nodes/ComfyWidgetNodes"; import type { SerializedLGraph, UUID } from "@litegraph-ts/core"; import type { SerializedLayoutState } from "./stores/layoutState"; +import type { ComfyNodeDef } from "./ComfyNodeDef"; export type ComfyPromptRequest = { client_id?: string, @@ -226,7 +227,7 @@ export default class ComfyAPI { * Loads node object definitions for the graph * @returns The node definitions */ - async getNodeDefs(): Promise { + async getNodeDefs(): Promise> { return fetch(this.getBackendUrl() + "/object_info", { cache: "no-store" }) .then(resp => resp.json()) } diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index fa094b3..c18a955 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -1,4 +1,4 @@ -import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID } from "@litegraph-ts/core"; +import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID, type NodeTypeSpec, type NodeTypeOpts } from "@litegraph-ts/core"; import type { LConnectionKind, INodeSlot } from "@litegraph-ts/core"; import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type ComfyNodeID, type PromptID } from "$lib/api" import { getPngMetadata, importA1111 } from "$lib/pnginfo"; @@ -34,13 +34,10 @@ import configState from "$lib/stores/configState"; import { blankGraph } from "$lib/defaultGraph"; import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes"; import ComfyPromptSerializer from "./ComfyPromptSerializer"; +import { iterateNodeDefInputs, type ComfyNodeDef, isBackendNodeDefInputType, iterateNodeDefOutputs } from "$lib/ComfyNodeDef"; export const COMFYBOX_SERIAL_VERSION = 1; -LiteGraph.catch_exceptions = false; -LiteGraph.CANVAS_GRID_SIZE = 32; -LiteGraph.default_subgraph_lgraph_factory = () => new ComfyGraph(); - if (typeof window !== "undefined") { // Load default visibility nodes.ComfyReroute.setDefaultTextVisibility(!!localStorage["Comfy.ComfyReroute.DefaultVisibility"]); @@ -130,8 +127,6 @@ export default class ComfyApp { this.lCanvas.allow_dragnodes = uiUnlocked; this.lCanvas.allow_interaction = uiUnlocked; - (window as any).LiteGraph = LiteGraph; - // await this.#invokeExtensionsAsync("init"); await this.registerNodes(); @@ -205,15 +200,11 @@ export default class ComfyApp { static widget_type_overrides: Record = {} private async registerNodes() { - const app = this; - // Load node definitions from the backend const defs = await this.api.getNodeDefs(); // Register a node for each definition - for (const nodeId in defs) { - const nodeData = defs[nodeId]; - + for (const [nodeId, nodeDef] of Object.entries(defs)) { const typeOverride = ComfyApp.node_type_overrides[nodeId] if (typeOverride) console.debug("Attaching custom type to received node:", nodeId, typeOverride) @@ -221,19 +212,42 @@ export default class ComfyApp { const ctor = class extends baseClass { constructor(title?: string) { - super(title, nodeId, nodeData); + super(title, nodeId, nodeDef); } } const node: LGraphNodeConstructor = { class: ctor, - title: nodeData.display_name || nodeData.name, + title: nodeDef.display_name || nodeDef.name, type: nodeId, desc: `ComfyNode: ${nodeId}` } LiteGraph.registerNodeType(node); - node.category = nodeData.category; + node.category = nodeDef.category; + + ComfyApp.registerDefaultSlotHandlers(nodeId, nodeDef) + } + } + + static registerDefaultSlotHandlers(nodeId: string, nodeDef: ComfyNodeDef) { + const nodeTypeSpec: NodeTypeOpts = { + node: nodeId, + title: nodeDef.display_name || nodeDef.name, + properties: null, + inputs: null, + outputs: null + } + + for (const [inputName, [inputType, _inputOpts]] of iterateNodeDefInputs(nodeDef)) { + if (isBackendNodeDefInputType(inputName, inputType)) { + LiteGraph.slot_types_default_out[inputType] ||= [] + LiteGraph.slot_types_default_out[inputType].push(nodeTypeSpec) + } + } + for (const output of iterateNodeDefOutputs(nodeDef)) { + LiteGraph.slot_types_default_in[output.type] ||= [] + LiteGraph.slot_types_default_in[output.type].push(nodeTypeSpec) } } diff --git a/src/lib/init.ts b/src/lib/init.ts new file mode 100644 index 0000000..913dfe5 --- /dev/null +++ b/src/lib/init.ts @@ -0,0 +1,19 @@ +import ComfyGraph from '$lib/ComfyGraph'; +import { LGraphCanvas, LiteGraph, Subgraph } from '@litegraph-ts/core'; + +export function configureLitegraph(isMobile: boolean = false) { + LiteGraph.catch_exceptions = false; + LiteGraph.use_uuids = true; + LiteGraph.CANVAS_GRID_SIZE = 32; + + if (isMobile) { + LiteGraph.dialog_close_on_mouse_leave = false; + LiteGraph.search_hide_on_mouse_leave = false; + LiteGraph.pointerevents_method = "pointer"; + } + + Subgraph.default_lgraph_factory = () => new ComfyGraph; + + (window as any).LiteGraph = LiteGraph; + (window as any).LGraphCanvas = LGraphCanvas; +} diff --git a/src/lib/nodes/ComfyBackendNode.ts b/src/lib/nodes/ComfyBackendNode.ts index fd17f00..1bf0f5c 100644 --- a/src/lib/nodes/ComfyBackendNode.ts +++ b/src/lib/nodes/ComfyBackendNode.ts @@ -5,6 +5,7 @@ import type { ComfyWidgetNode, ComfyExecutionResult } from "./ComfyWidgetNodes"; import { BuiltInSlotShape, BuiltInSlotType, type SerializedLGraphNode } from "@litegraph-ts/core"; import type IComfyInputSlot from "$lib/IComfyInputSlot"; import type { ComfyInputConfig } from "$lib/IComfyInputSlot"; +import { iterateNodeDefOutputs, type ComfyNodeDef, iterateNodeDefInputs } from "$lib/ComfyNodeDef"; /* * Base class for any node with configuration sent by the backend. @@ -37,22 +38,19 @@ export class ComfyBackendNode extends ComfyGraphNode { // comfy class -> input name -> input config private static defaultInputConfigs: Record> = {} - private setup(nodeData: any) { - var inputs = nodeData["input"]["required"]; - if (nodeData["input"]["optional"] != undefined) { - inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"]) - } - + private setup(nodeDef: ComfyNodeDef) { ComfyBackendNode.defaultInputConfigs[this.type] = {} - for (const inputName in inputs) { + for (const [inputName, inputData] of iterateNodeDefInputs(nodeDef)) { const config: Partial = {}; - const inputData = inputs[inputName]; - const type = inputData[0]; + const [type, opts] = inputData; - if (inputData[1]?.forceInput) { - this.addInput(inputName, type); + if (opts?.forceInput) { + if (Array.isArray(type)) { + throw new Error(`Can't have forceInput set to true for an enum type! ${type}`) + } + this.addInput(inputName, type as string); } else { if (Array.isArray(type)) { // Enums @@ -73,11 +71,9 @@ export class ComfyBackendNode extends ComfyGraphNode { ComfyBackendNode.defaultInputConfigs[this.type][inputName] = (config as IComfyInputSlot).config } - for (const o in nodeData["output"]) { - const output = nodeData["output"][o]; - const outputName = nodeData["output_name"][o] || output; - const outputShape = nodeData["output_is_list"][o] ? BuiltInSlotShape.GRID_SHAPE : BuiltInSlotShape.CIRCLE_SHAPE; - this.addOutput(outputName, output, { shape: outputShape }); + for (const output of iterateNodeDefOutputs(nodeDef)) { + const outputShape = output.is_list ? BuiltInSlotShape.GRID_SHAPE : BuiltInSlotShape.CIRCLE_SHAPE; + this.addOutput(output.name, output.type, { shape: outputShape }); } this.serialize_widgets = false; From b515ac885a207a8c8624580c78cf9ee0dd9d0786 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Tue, 16 May 2023 17:11:45 -0500 Subject: [PATCH 09/38] Remove continuous value inputs from widgets --- src/lib/nodes/ComfyWidgetNodes.ts | 56 +++++++++---------------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/src/lib/nodes/ComfyWidgetNodes.ts b/src/lib/nodes/ComfyWidgetNodes.ts index e1b559a..1e2b4de 100644 --- a/src/lib/nodes/ComfyWidgetNodes.ts +++ b/src/lib/nodes/ComfyWidgetNodes.ts @@ -89,16 +89,11 @@ export abstract class ComfyWidgetNode extends ComfyGraphNode { override isBackendNode = false; override serialize_widgets = true; - - // TODO these are bad, create override methods instead - // input slots - inputIndex: number | null = null; storeActionName: string | null = "store"; // output slots - outputIndex: number | null = 0; - changedIndex: number | null = 1; - + outputSlotName: string | null = "value"; + changedEventName: string | null = "changed"; displayWidget: ITextWidget; @@ -145,11 +140,13 @@ export abstract class ComfyWidgetNode extends ComfyGraphNode { // console.debug("[Widget] valueUpdated", this, value) this.displayWidget.value = this.formatValue(value) - if (this.outputIndex !== null && this.outputs.length >= this.outputIndex) { - this.setOutputData(this.outputIndex, get(this.value)) + if (this.outputSlotName !== null) { + const outputIndex = this.findOutputSlotIndexByName(this.outputSlotName) + if (outputIndex !== -1) + this.setOutputData(outputIndex, get(this.value)) } - if (this.changedIndex !== null && this.outputs.length >= this.changedIndex && !this._noChangedEvent) { + if (this.changedEventName !== null && !this._noChangedEvent) { if (!this.delayChangedEvent) this.triggerChangeEvent(get(this.value)) else { @@ -163,9 +160,7 @@ export abstract class ComfyWidgetNode extends ComfyGraphNode { private triggerChangeEvent(value: any) { // console.debug("[Widget] trigger changed", this, value) - const changedOutput = this.outputs[this.changedIndex] - if (changedOutput.type === BuiltInSlotType.EVENT) - this.triggerSlot(this.changedIndex, value) + this.trigger(this.changedEventName, value) } parseValue(value: any): T { return value as T }; @@ -198,18 +193,10 @@ export abstract class ComfyWidgetNode extends ComfyGraphNode { * Logic to run if this widget can be treated as output (slider, combo, text) */ override onExecute(param: any, options: object) { - if (this.inputIndex != null) { - if (this.inputs.length >= this.inputIndex) { - const data = this.getInputData(this.inputIndex) - if (data != null) { // TODO can "null" be a legitimate value here? - this.setValue(data) - } - } - } - if (this.outputIndex != null) { - if (this.outputs.length >= this.outputIndex) { - this.setOutputData(this.outputIndex, get(this.value)) - } + if (this.outputSlotName != null) { + const outputIndex = this.findOutputSlotIndexByName(this.outputSlotName) + if (outputIndex !== -1) + this.setOutputData(outputIndex, get(this.value)) } for (const propName in this.shownOutputProperties) { const data = this.shownOutputProperties[propName] @@ -373,7 +360,6 @@ export class ComfySliderNode extends ComfyWidgetNode { static slotLayout: SlotLayout = { inputs: [ - { name: "value", type: "number" }, { name: "store", type: BuiltInSlotType.ACTION } ], outputs: [ @@ -431,7 +417,6 @@ export class ComfyComboNode extends ComfyWidgetNode { static slotLayout: SlotLayout = { inputs: [ - { name: "value", type: "string" }, { name: "store", type: BuiltInSlotType.ACTION } ], outputs: [ @@ -574,7 +559,6 @@ export class ComfyTextNode extends ComfyWidgetNode { static slotLayout: SlotLayout = { inputs: [ - { name: "value", type: "string" }, { name: "store", type: BuiltInSlotType.ACTION } ], outputs: [ @@ -650,10 +634,9 @@ export class ComfyGalleryNode extends ComfyWidgetNode { override svelteComponentType = GalleryWidget override defaultValue = [] - override inputIndex = null; override saveUserState = false; - override outputIndex = null; - override changedIndex = null; + override outputSlotName = null; + override changedEventName = null; selectedFilename: string | null = null; @@ -727,13 +710,13 @@ export class ComfyButtonNode extends ComfyWidgetNode { static slotLayout: SlotLayout = { outputs: [ { name: "clicked", type: BuiltInSlotType.EVENT }, - { name: "isClicked", type: "boolean" }, ] } override svelteComponentType = ButtonWidget; override defaultValue = false; - override outputIndex = 1; + override outputSlotName = null; + override changedEventName = null; constructor(name?: string) { super(name, false) @@ -768,7 +751,6 @@ export class ComfyCheckboxNode extends ComfyWidgetNode { static slotLayout: SlotLayout = { inputs: [ - { name: "value", type: "boolean" }, { name: "store", type: BuiltInSlotType.ACTION } ], outputs: [ @@ -779,7 +761,6 @@ export class ComfyCheckboxNode extends ComfyWidgetNode { override svelteComponentType = CheckboxWidget; override defaultValue = false; - override changedIndex = 1; constructor(name?: string) { super(name, false) @@ -810,7 +791,6 @@ export class ComfyRadioNode extends ComfyWidgetNode { static slotLayout: SlotLayout = { inputs: [ - { name: "value", type: "string,number" }, { name: "store", type: BuiltInSlotType.ACTION } ], outputs: [ @@ -822,7 +802,6 @@ export class ComfyRadioNode extends ComfyWidgetNode { override svelteComponentType = RadioWidget; override defaultValue = ""; - override changedIndex = 2; indexWidget: INumberWidget; @@ -894,9 +873,6 @@ export class ComfyImageEditorNode extends ComfyWidgetNode Date: Tue, 16 May 2023 19:04:34 -0500 Subject: [PATCH 10/38] Two-way selection --- litegraph | 2 +- src/lib/ComfyGraph.ts | 8 +- src/lib/ComfyGraphCanvas.ts | 148 +++++++++++++------ src/lib/components/AccordionContainer.svelte | 8 +- src/lib/components/BlockContainer.svelte | 8 +- src/lib/components/ComfyApp.svelte | 5 +- src/lib/components/ComfyApp.ts | 1 - src/lib/components/ComfyProperties.svelte | 9 +- src/lib/components/ComfyUIPane.svelte | 32 ++-- src/lib/components/Container.svelte | 3 +- src/lib/components/TabsContainer.svelte | 8 +- src/lib/components/WidgetContainer.svelte | 27 ++-- src/lib/init.ts | 3 + src/lib/nodes/ComfyWidgetNodes.ts | 6 +- src/lib/stores/layoutState.ts | 41 ++--- src/lib/stores/selectionState.ts | 57 +++++++ src/lib/utils.ts | 30 ++-- src/scss/global.scss | 8 + 18 files changed, 278 insertions(+), 126 deletions(-) create mode 100644 src/lib/stores/selectionState.ts diff --git a/litegraph b/litegraph index b9762fa..5fe79f7 160000 --- a/litegraph +++ b/litegraph @@ -1 +1 @@ -Subproject commit b9762fa6d56bf6b1f4f61361b6f9cd2e3972de1d +Subproject commit 5fe79f73971ed8869c680380307fae14e5ca5cae diff --git a/src/lib/ComfyGraph.ts b/src/lib/ComfyGraph.ts index 3022664..6eb2561 100644 --- a/src/lib/ComfyGraph.ts +++ b/src/lib/ComfyGraph.ts @@ -88,7 +88,7 @@ export default class ComfyGraph extends LGraph { if (!("svelteComponentType" in node) && options.addedBy == null) { console.debug("[ComfyGraph] AutoAdd UI") const comfyNode = node as ComfyGraphNode; - const widgetNodesAdded = [] + const widgetNodesAdded: ComfyWidgetNode[] = [] for (let index = 0; index < comfyNode.inputs.length; index++) { const input = comfyNode.inputs[index]; if ("config" in input) { @@ -109,10 +109,10 @@ export default class ComfyGraph extends LGraph { } } } - const dragItems = widgetNodesAdded.map(wn => get(layoutState).allItemsByNode[wn.id]?.dragItem).filter(di => di) - console.debug("[ComfyGraph] Group new widgets", dragItems) + const dragItemIDs = widgetNodesAdded.map(wn => get(layoutState).allItemsByNode[wn.id]?.dragItem?.id).filter(Boolean) + console.debug("[ComfyGraph] Group new widgets", dragItemIDs) - layoutState.groupItems(dragItems, { title: node.title }) + layoutState.groupItems(dragItemIDs, { title: node.title }) } } diff --git a/src/lib/ComfyGraphCanvas.ts b/src/lib/ComfyGraphCanvas.ts index 3da6311..45fc6e3 100644 --- a/src/lib/ComfyGraphCanvas.ts +++ b/src/lib/ComfyGraphCanvas.ts @@ -1,11 +1,13 @@ -import { BuiltInSlotShape, LGraph, LGraphCanvas, LGraphNode, LiteGraph, NodeMode, type MouseEventExt, type Vector2, type Vector4, TitleMode, type ContextMenuItem, type IContextMenuItem, Subgraph, LLink } from "@litegraph-ts/core"; +import { BuiltInSlotShape, LGraph, LGraphCanvas, LGraphNode, LiteGraph, NodeMode, type MouseEventExt, type Vector2, type Vector4, TitleMode, type ContextMenuItem, type IContextMenuItem, Subgraph, LLink, type NodeID } from "@litegraph-ts/core"; import type ComfyApp from "./components/ComfyApp"; import queueState from "./stores/queueState"; -import { get } from "svelte/store"; +import { get, type Unsubscriber } from "svelte/store"; import uiState from "./stores/uiState"; import layoutState from "./stores/layoutState"; import { Watch } from "@litegraph-ts/nodes-basic"; import { ComfyReroute } from "./nodes"; +import type { Progress } from "./components/ComfyApp"; +import selectionState from "./stores/selectionState"; export type SerializedGraphCanvasState = { offset: Vector2, @@ -14,6 +16,7 @@ export type SerializedGraphCanvasState = { export default class ComfyGraphCanvas extends LGraphCanvas { app: ComfyApp | null; + private _unsubscribe: Unsubscriber; constructor( app: ComfyApp, @@ -27,8 +30,22 @@ export default class ComfyGraphCanvas extends LGraphCanvas { ) { super(canvas, app.lGraph, options); this.app = app; + this._unsubscribe = selectionState.subscribe(ss => { + for (const node of Object.values(this.selected_nodes)) { + node.is_selected = false; + } + this.selected_nodes = {} + for (const node of ss.currentSelectionNodes) { + this.selected_nodes[node.id] = node; + node.is_selected = true + } + this._selectedNodes = new Set() + this.setDirty(true, true); + }) } + _selectedNodes: Set = new Set(); + serialize(): SerializedGraphCanvasState { return { offset: this.ds.offset, @@ -58,51 +75,61 @@ export default class ComfyGraphCanvas extends LGraphCanvas { super.drawNodeShape(node, ctx, size, fgColor, bgColor, selected, mouseOver); let state = get(queueState); + let ss = get(selectionState); let color = null; - if (node.id === +state.runningNodeID) { + let thickness = 1; + // if (this._selectedNodes.has(node.id)) { + // color = "yellow"; + // thickness = 5; + // } + if (ss.currentHoveredNodes.has(node.id)) { + color = "lightblue"; + } + else if (node.id === +state.runningNodeID) { color = "#0f0"; - // this.app can be null inside the constructor if rendering is taking place already - } else if (this.app && this.app.dragOverNode && node.id === this.app.dragOverNode.id) { - color = "dodgerblue"; } if (color) { - const shape = node.shape || BuiltInSlotShape.ROUND_SHAPE; - ctx.lineWidth = 1; - ctx.globalAlpha = 0.8; - ctx.beginPath(); - if (shape == BuiltInSlotShape.BOX_SHAPE) - ctx.rect(-6, -6 + LiteGraph.NODE_TITLE_HEIGHT, 12 + size[0] + 1, 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT); - else if (shape == BuiltInSlotShape.ROUND_SHAPE || (shape == BuiltInSlotShape.CARD_SHAPE && node.flags.collapsed)) - ctx.roundRect( - -6, - -6 - LiteGraph.NODE_TITLE_HEIGHT, - 12 + size[0] + 1, - 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT, - this.round_radius * 2 - ); - else if (shape == BuiltInSlotShape.CARD_SHAPE) - ctx.roundRect( - -6, - -6 + LiteGraph.NODE_TITLE_HEIGHT, - 12 + size[0] + 1, - 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT, - this.round_radius * 2, - 2 - ); - else if (shape == BuiltInSlotShape.CIRCLE_SHAPE) - ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2); - ctx.strokeStyle = color; - ctx.stroke(); - ctx.strokeStyle = fgColor; - ctx.globalAlpha = 1; + this.drawNodeOutline(node, ctx, state.progress, size, fgColor, bgColor, color, thickness) + } + } - if (state.progress) { - ctx.fillStyle = "green"; - ctx.fillRect(0, 0, size[0] * (state.progress.value / state.progress.max), 6); - ctx.fillStyle = bgColor; - } + private drawNodeOutline(node: LGraphNode, ctx: CanvasRenderingContext2D, progress?: Progress, size: Vector2, fgColor: string, bgColor: string, outlineColor: string, outlineThickness: number) { + const shape = node.shape || BuiltInSlotShape.ROUND_SHAPE; + ctx.lineWidth = outlineThickness; + ctx.globalAlpha = 0.8; + ctx.beginPath(); + if (shape == BuiltInSlotShape.BOX_SHAPE) + ctx.rect(-6, -6 + LiteGraph.NODE_TITLE_HEIGHT, 12 + size[0] + 1, 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT); + else if (shape == BuiltInSlotShape.ROUND_SHAPE || (shape == BuiltInSlotShape.CARD_SHAPE && node.flags.collapsed)) + ctx.roundRect( + -6, + -6 - LiteGraph.NODE_TITLE_HEIGHT, + 12 + size[0] + 1, + 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT, + this.round_radius * 2 + ); + else if (shape == BuiltInSlotShape.CARD_SHAPE) + ctx.roundRect( + -6, + -6 + LiteGraph.NODE_TITLE_HEIGHT, + 12 + size[0] + 1, + 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT, + this.round_radius * 2, + 2 + ); + else if (shape == BuiltInSlotShape.CIRCLE_SHAPE) + ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2); + ctx.strokeStyle = outlineColor; + ctx.stroke(); + ctx.strokeStyle = fgColor; + ctx.globalAlpha = 1; + + if (progress) { + ctx.fillStyle = "green"; + ctx.fillRect(0, 0, size[0] * (progress.value / progress.max), 6); + ctx.fillStyle = bgColor; } } @@ -235,10 +262,43 @@ export default class ComfyGraphCanvas extends LGraphCanvas { } override onSelectionChange(nodes: Record) { - const ls = get(layoutState) - ls.currentSelectionNodes = Object.values(nodes) - ls.currentSelection = [] - layoutState.set(ls) + selectionState.update(ss => { + ss.currentSelectionNodes = Object.values(nodes) + ss.currentSelection = [] + const ls = get(layoutState) + for (const node of ss.currentSelectionNodes) { + const widget = ls.allItemsByNode[node.id] + if (widget) + ss.currentSelection.push(widget.dragItem.id) + } + return ss + }) + } + + override onHoverChange(node: LGraphNode | null) { + selectionState.update(ss => { + ss.currentHoveredNodes.clear() + if (node) { + ss.currentHoveredNodes.add(node.id) + } + ss.currentHovered.clear() + const ls = get(layoutState) + for (const nodeID of ss.currentHoveredNodes) { + const widget = ls.allItemsByNode[nodeID] + if (widget) + ss.currentHovered.add(widget.dragItem.id) + } + return ss + }) + } + + override clear() { + super.clear(); + selectionState.update(ss => { + ss.currentSelectionNodes = []; + ss.currentHoveredNodes.clear() + return ss; + }) } override onNodeMoved(node: LGraphNode) { diff --git a/src/lib/components/AccordionContainer.svelte b/src/lib/components/AccordionContainer.svelte index 978da9e..e0cf0c1 100644 --- a/src/lib/components/AccordionContainer.svelte +++ b/src/lib/components/AccordionContainer.svelte @@ -2,6 +2,7 @@ import { Block, BlockTitle } from "@gradio/atoms"; import Accordion from "$lib/components/gradio/app/Accordion.svelte"; import uiState from "$lib/stores/uiState"; + import selectionState from "$lib/stores/selectionState"; import WidgetContainer from "./WidgetContainer.svelte" import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action'; @@ -61,9 +62,10 @@ {#if container && children} + {@const selected = $uiState.uiUnlocked && $selectionState.currentSelection.includes(container.id)}
@@ -120,6 +122,10 @@ diff --git a/src/lib/widgets/NumberWidget.svelte b/src/lib/widgets/NumberWidget.svelte new file mode 100644 index 0000000..87c9294 --- /dev/null +++ b/src/lib/widgets/NumberWidget.svelte @@ -0,0 +1,173 @@ + + +
+ {#if node !== null && option !== null} + + {/if} +
+ + diff --git a/src/lib/widgets/RadioWidget.svelte b/src/lib/widgets/RadioWidget.svelte index c22eef9..f927d09 100644 --- a/src/lib/widgets/RadioWidget.svelte +++ b/src/lib/widgets/RadioWidget.svelte @@ -1,5 +1,4 @@ - -
- {#if node !== null && option !== null} - - {/if} -
- - diff --git a/src/lib/widgets/TextWidget.svelte b/src/lib/widgets/TextWidget.svelte index 79c8dbc..288602c 100644 --- a/src/lib/widgets/TextWidget.svelte +++ b/src/lib/widgets/TextWidget.svelte @@ -1,21 +1,21 @@ {#if container && children} diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index 72636a7..aff7964 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -1,4 +1,4 @@ -import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID, type NodeTypeSpec, type NodeTypeOpts } from "@litegraph-ts/core"; +import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID, type NodeTypeSpec, type NodeTypeOpts, type SlotIndex } from "@litegraph-ts/core"; import type { LConnectionKind, INodeSlot } from "@litegraph-ts/core"; import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type ComfyNodeID, type PromptID } from "$lib/api" import { getPngMetadata, importA1111 } from "$lib/pnginfo"; @@ -34,7 +34,7 @@ import notify from "$lib/notify"; import configState from "$lib/stores/configState"; import { blankGraph } from "$lib/defaultGraph"; import type { ComfyExecutionResult } from "$lib/utils"; -import ComfyPromptSerializer from "./ComfyPromptSerializer"; +import ComfyPromptSerializer, { UpstreamNodeLocator, isActiveBackendNode } from "./ComfyPromptSerializer"; import { iterateNodeDefInputs, type ComfyNodeDef, isBackendNodeDefInputType, iterateNodeDefOutputs } from "$lib/ComfyNodeDef"; import { ComfyComboNode } from "$lib/nodes/widgets"; @@ -80,7 +80,7 @@ export type Progress = { type BackendComboNode = { comboNode: ComfyComboNode, - inputSlot: IComfyInputSlot, + comfyInput: IComfyInputSlot, backendNode: ComfyBackendNode } @@ -709,13 +709,13 @@ export default class ComfyApp { async refreshComboInNodes(flashUI: boolean = false) { const defs = await this.api.getNodeDefs(); - const isComfyComboNode = (node: LGraphNode): boolean => { + const isComfyComboNode = (node: LGraphNode): node is ComfyComboNode => { return node && node.type === "ui/combo" && "doAutoConfig" in node; } - const isComfyComboInput = (input: INodeInputSlot) => { + const isComfyComboInput = (input: INodeInputSlot): input is IComfyInputSlot => { return "config" in input && "widgetNodeType" in input && input.widgetNodeType === "ui/combo"; @@ -729,34 +729,42 @@ export default class ComfyApp { // Figure out which combo nodes to update. They need to be connected to // an input slot on a backend node with a backend config in the input // slot connected to. + const nodeLocator = new UpstreamNodeLocator(isComfyComboNode) + + const findComfyInputAndAttachedCombo = (node: LGraphNode, i: SlotIndex): [IComfyInputSlot, ComfyComboNode] | null => { + const input = node.inputs[i] + + // Does this input autocreate a combo box on creation? + const isComfyInput = isComfyComboInput(input) + if (!isComfyInput) + return null; + + // Find an attached combo node even if it's inside/outside of a + // subgraph, linked after several nodes, etc. + const [comboNode, _link] = nodeLocator.locateUpstream(node, i, null); + + if (comboNode == null) + return null; + + const result: [IComfyInputSlot, ComfyComboNode] = [input, comboNode as ComfyComboNode] + return result + } + for (const node of this.lGraph.iterateNodesInOrderRecursive()) { - if (!(node as any).isBackendNode) + if (!isActiveBackendNode(node)) continue; - const backendNode = (node as ComfyBackendNode) - const found = range(backendNode.inputs.length) - .filter(i => { - const input = backendNode.inputs[i] - const inputNode = backendNode.getInputNode(i) + const found = range(node.inputs.length) + .map((i) => findComfyInputAndAttachedCombo(node, i)) + .filter(Boolean); - // Does this input autocreate a combo box on creation? - const isComfyInput = isComfyComboInput(input) - const isComfyCombo = isComfyComboNode(inputNode) + for (const [comfyInput, comboNode] of found) { + const def = defs[node.type]; - // console.debug("[refreshComboInNodes] CHECK", backendNode.type, input.name, "isComfyCombo", isComfyCombo, "isComfyInput", isComfyInput) - - return isComfyCombo && isComfyInput - }); - - for (const inputIndex of found) { - const comboNode = backendNode.getInputNode(inputIndex) as ComfyComboNode - const inputSlot = backendNode.inputs[inputIndex] as IComfyInputSlot; - const def = defs[backendNode.type]; - - const hasBackendConfig = def["input"]["required"][inputSlot.name] !== undefined + const hasBackendConfig = def["input"]["required"][comfyInput.name] !== undefined if (hasBackendConfig) { - backendUpdatedCombos[comboNode.id] = { comboNode, inputSlot, backendNode } + backendUpdatedCombos[comboNode.id] = { comboNode, comfyInput, backendNode: node } } } } @@ -792,12 +800,12 @@ export default class ComfyApp { await tick(); // Load definitions from the backend. - for (const { comboNode, inputSlot, backendNode } of Object.values(backendUpdatedCombos)) { + for (const { comboNode, comfyInput, backendNode } of Object.values(backendUpdatedCombos)) { const def = defs[backendNode.type]; - const rawValues = def["input"]["required"][inputSlot.name][0]; + const rawValues = def["input"]["required"][comfyInput.name][0]; console.debug("[ComfyApp] Reconfiguring combo widget", backendNode.type, "=>", comboNode.type, rawValues.length) - comboNode.doAutoConfig(inputSlot, { includeProperties: new Set(["values"]), setWidgetTitle: false }) + comboNode.doAutoConfig(comfyInput, { includeProperties: new Set(["values"]), setWidgetTitle: false }) comboNode.formatValues(rawValues as string[]) if (!rawValues?.includes(get(comboNode.value))) { diff --git a/src/lib/components/ComfyPromptSerializer.ts b/src/lib/components/ComfyPromptSerializer.ts index b112d4f..7a26fcc 100644 --- a/src/lib/components/ComfyPromptSerializer.ts +++ b/src/lib/components/ComfyPromptSerializer.ts @@ -1,7 +1,7 @@ import type ComfyGraph from "$lib/ComfyGraph"; import type { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; -import { GraphInput, GraphOutput, LGraph, LGraphNode, LLink, NodeMode, Subgraph } from "@litegraph-ts/core"; +import { GraphInput, GraphOutput, LGraph, LGraphNode, LLink, NodeMode, Subgraph, type SlotIndex } from "@litegraph-ts/core"; import type { SerializedPrompt, SerializedPromptInput, SerializedPromptInputs, SerializedPromptInputsAll } from "./ComfyApp"; import type IComfyInputSlot from "$lib/IComfyInputSlot"; @@ -9,8 +9,8 @@ function hasTag(node: LGraphNode, tag: string): boolean { return "tags" in node.properties && node.properties.tags.indexOf(tag) !== -1 } -function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): node is ComfyBackendNode { - if (!node.isBackendNode) +export function isActiveNode(node: LGraphNode, tag: string | null = null): boolean { + if (!node) return false; if (tag && !hasTag(node, tag)) { @@ -18,7 +18,7 @@ function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): node is return false; } - if (node.mode === NodeMode.NEVER) { + if (node.mode !== NodeMode.ALWAYS) { // Don't serialize muted nodes return false; } @@ -26,48 +26,122 @@ function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): node is return true; } -function followSubgraph(subgraph: Subgraph, link: LLink): [LGraph | null, LLink | null] { - if (link.origin_id != subgraph.id) - throw new Error("A!") +export function isActiveBackendNode(node: LGraphNode, tag: string | null = null): node is ComfyBackendNode { + if (!(node as any).isBackendNode) + return false; - const innerGraphOutput = subgraph.getInnerGraphOutputByIndex(link.origin_slot) - if (innerGraphOutput == null) - throw new Error("No inner graph input!") - - const nextLink = innerGraphOutput.getInputLink(0) - return [innerGraphOutput.graph, nextLink]; + return isActiveNode(node, tag); } -function followGraphInput(graphInput: GraphInput, link: LLink): [LGraph | null, LLink | null] { - if (link.origin_id != graphInput.id) - throw new Error("A!") - - const outerSubgraph = graphInput.getParentSubgraph(); - if (outerSubgraph == null) - throw new Error("No outer subgraph!") - - const outerInputIndex = outerSubgraph.inputs.findIndex(i => i.name === graphInput.nameInGraph) - if (outerInputIndex == null) - throw new Error("No outer input slot!") - - const nextLink = outerSubgraph.getInputLink(outerInputIndex) - return [outerSubgraph.graph, nextLink]; -} - -function getUpstreamLink(parent: LGraphNode, currentLink: LLink): [LGraph | null, LLink | null] { - if (parent.is(Subgraph)) { - console.warn("FollowSubgraph") - return followSubgraph(parent, currentLink); +export class UpstreamNodeLocator { + constructor(private isTheTargetNode: (node: LGraphNode) => boolean) { } - else if (parent.is(GraphInput)) { - console.warn("FollowGraphInput") - return followGraphInput(parent, currentLink); + + private followSubgraph(subgraph: Subgraph, link: LLink): [LGraph | null, LLink | null] { + if (link.origin_id != subgraph.id) + throw new Error("Invalid link and graph output!") + + const innerGraphOutput = subgraph.getInnerGraphOutputByIndex(link.origin_slot) + if (innerGraphOutput == null) + throw new Error("No inner graph input!") + + const nextLink = innerGraphOutput.getInputLink(0) + return [innerGraphOutput.graph, nextLink]; } - else if ("getUpstreamLink" in parent) { - return [parent.graph, (parent as ComfyGraphNode).getUpstreamLink()]; + + private followGraphInput(graphInput: GraphInput, link: LLink): [LGraph | null, LLink | null] { + if (link.origin_id != graphInput.id) + throw new Error("Invalid link and graph input!") + + const outerSubgraph = graphInput.getParentSubgraph(); + if (outerSubgraph == null) + throw new Error("No outer subgraph!") + + const outerInputIndex = outerSubgraph.inputs.findIndex(i => i.name === graphInput.nameInGraph) + if (outerInputIndex == null) + throw new Error("No outer input slot!") + + const nextLink = outerSubgraph.getInputLink(outerInputIndex) + return [outerSubgraph.graph, nextLink]; + } + + private getUpstreamLink(parent: LGraphNode, currentLink: LLink): [LGraph | null, LLink | null] { + if (parent.is(Subgraph)) { + console.debug("FollowSubgraph") + return this.followSubgraph(parent, currentLink); + } + else if (parent.is(GraphInput)) { + console.debug("FollowGraphInput") + return this.followGraphInput(parent, currentLink); + } + else if ("getUpstreamLink" in parent) { + return [parent.graph, (parent as ComfyGraphNode).getUpstreamLink()]; + } + else if (parent.inputs.length === 1) { + // Only one input, so assume we can follow it backwards. + const link = parent.getInputLink(0); + if (link) { + return [parent.graph, link] + } + } + console.warn("[graphToPrompt] Frontend node does not support getUpstreamLink", parent.type) + return [null, null]; + } + + /* + * Traverses the graph upstream from outputs towards inputs across + * a sequence of nodes dependent on a condition. + * + * Returns the node and the output link attached to it that leads to the + * starting node if any. + */ + locateUpstream(fromNode: LGraphNode, inputIndex: SlotIndex, tag: string | null): [LGraphNode | null, LLink | null] { + let parent = fromNode.getInputNode(inputIndex); + if (!parent) + return [null, null]; + + const seen = {} + let currentLink = fromNode.getInputLink(inputIndex); + + const shouldFollowParent = (parent: LGraphNode) => { + return isActiveNode(parent, tag) && !this.isTheTargetNode(parent); + } + + // If there are non-target nodes between us and another + // backend node, we have to traverse them first. This + // behavior is dependent on the type of node. Reroute nodes + // will simply follow their single input, while branching + // nodes have conditional logic that determines which link + // to follow backwards. + while (shouldFollowParent(parent)) { + const [nextGraph, nextLink] = this.getUpstreamLink(parent, currentLink); + + if (nextLink == null) { + console.warn("[graphToPrompt] No upstream link found in frontend node", parent) + break; + } + + if (nextLink && !seen[nextLink.id]) { + seen[nextLink.id] = true + const nextParent = nextGraph.getNodeById(nextLink.origin_id); + if (!isActiveNode(parent, tag)) { + parent = null; + } + else { + console.debug("[graphToPrompt] Traverse upstream link", parent.id, nextParent?.id, (nextParent as any)?.isBackendNode) + currentLink = nextLink; + parent = nextParent; + } + } else { + parent = null; + } + } + + if (!isActiveNode(parent, tag) || !this.isTheTargetNode(parent) || currentLink == null) + return [null, null]; + + return [parent, currentLink] } - console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type) - return [null, null]; } export default class ComfyPromptSerializer { @@ -129,66 +203,21 @@ export default class ComfyPromptSerializer { serializeBackendLinks(node: ComfyBackendNode, tag: string | null): Record { const inputs = {} + // Find a backend node upstream following before any number of frontend nodes + const test = (node: LGraphNode) => (node as any).isBackendNode + const nodeLocator = new UpstreamNodeLocator(test) + // Store links between backend-only and hybrid nodes for (let i = 0; i < node.inputs.length; i++) { - let parent = node.getInputNode(i); - if (parent) { - const seen = {} - let currentLink = node.getInputLink(i); - - const isFrontendParent = (parent: LGraphNode) => { - if (!parent || (parent as any).isBackendNode) - return false; - if (tag && !hasTag(parent, tag)) - return false; - return true; - } - - // If there are frontend-only nodes between us and another - // backend node, we have to traverse them first. This - // behavior is dependent on the type of node. Reroute nodes - // will simply follow their single input, while branching - // nodes have conditional logic that determines which link - // to follow backwards. - while (isFrontendParent(parent)) { - const [nextGraph, nextLink] = getUpstreamLink(parent, currentLink); - - if (nextLink == null) { - console.warn("[graphToPrompt] No upstream link found in frontend node", parent) - break; - } - - if (nextLink && !seen[nextLink.id]) { - seen[nextLink.id] = true - const nextParent = nextGraph.getNodeById(nextLink.origin_id); - if (nextParent && tag && !hasTag(nextParent, tag)) { - console.debug("[graphToPrompt] Skipping tagged intermediate frontend node", tag, node.properties.tags) - parent = null; - } - else { - console.debug("[graphToPrompt] Traverse upstream link", parent.id, nextParent?.id, (nextParent as any)?.isBackendNode) - currentLink = nextLink; - parent = nextParent; - } - } else { - parent = null; - } - } - - if (currentLink && parent && (parent as any).isBackendNode) { - if (tag && !hasTag(parent, tag)) - continue; - - console.debug("[graphToPrompt] final link", parent.id, node.id) - const input = node.inputs[i] - // TODO can null be a legitimate value in some cases? - // Nodes like CLIPLoader will never have a value in the frontend, hence "null". - if (!(input.name in inputs)) - inputs[input.name] = [String(currentLink.origin_id), currentLink.origin_slot]; - } - else { - console.warn("[graphToPrompt] Didn't find upstream link!", currentLink, parent?.id) - } + const [backendNode, linkLeadingTo] = nodeLocator.locateUpstream(node, i, tag) + if (backendNode) { + console.debug("[graphToPrompt] final link", backendNode.id, "-->", node.id) + const input = node.inputs[i] + if (!(input.name in inputs)) + inputs[input.name] = [String(linkLeadingTo.origin_id), linkLeadingTo.origin_slot]; + } + else { + console.warn("[graphToPrompt] Didn't find upstream link!", node.id, node.type, node.title) } } diff --git a/src/lib/nodes/widgets/ComfyButtonNode.ts b/src/lib/nodes/widgets/ComfyButtonNode.ts new file mode 100644 index 0000000..ed82a65 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyButtonNode.ts @@ -0,0 +1,48 @@ +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import ButtonWidget from "$lib/widgets/ButtonWidget.svelte"; +import ComfyWidgetNode, { type ComfyWidgetProperties } from "./ComfyWidgetNode"; + +export interface ComfyButtonProperties extends ComfyWidgetProperties { + param: string +} + +export default class ComfyButtonNode extends ComfyWidgetNode { + override properties: ComfyButtonProperties = { + tags: [], + defaultValue: false, + param: "bang" + } + + static slotLayout: SlotLayout = { + outputs: [ + { name: "clicked", type: BuiltInSlotType.EVENT }, + ] + } + + override svelteComponentType = ButtonWidget; + override defaultValue = false; + override outputSlotName = null; + override changedEventName = null; + + constructor(name?: string) { + super(name, false) + } + + override parseValue(param: any): boolean { + return Boolean(param); + } + + onClick() { + this.setValue(true) + this.triggerSlot(0, this.properties.param); + this.setValue(false) // TODO onRelease + } +} + +LiteGraph.registerNodeType({ + class: ComfyButtonNode, + title: "UI.Button", + desc: "Button that triggers an event when clicked", + type: "ui/button" +}) diff --git a/src/lib/nodes/widgets/ComfyCheckboxNode.ts b/src/lib/nodes/widgets/ComfyCheckboxNode.ts new file mode 100644 index 0000000..91aebf6 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyCheckboxNode.ts @@ -0,0 +1,43 @@ +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import CheckboxWidget from "$lib/widgets/CheckboxWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + +export interface ComfyCheckboxProperties extends ComfyWidgetProperties { +} + +export default class ComfyCheckboxNode extends ComfyWidgetNode { + override properties: ComfyCheckboxProperties = { + tags: [], + defaultValue: false, + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "boolean" }, + { name: "changed", type: BuiltInSlotType.EVENT }, + ] + } + + override svelteComponentType = CheckboxWidget; + override defaultValue = false; + + constructor(name?: string) { + super(name, false) + } + + override parseValue(param: any) { + return Boolean(param); + } +} + +LiteGraph.registerNodeType({ + class: ComfyCheckboxNode, + title: "UI.Checkbox", + desc: "Checkbox that stores a boolean value", + type: "ui/checkbox" +}) diff --git a/src/lib/nodes/widgets/ComfyComboNode.ts b/src/lib/nodes/widgets/ComfyComboNode.ts new file mode 100644 index 0000000..e44a800 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyComboNode.ts @@ -0,0 +1,154 @@ +import type IComfyInputSlot from "$lib/IComfyInputSlot"; +import { BuiltInSlotType, LiteGraph, type INodeInputSlot, type LGraphNode, type SerializedLGraphNode, type SlotLayout } from "@litegraph-ts/core"; +import { writable, type Writable } from "svelte/store"; + +import ComboWidget from "$lib/widgets/ComboWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + + +export interface ComfyComboProperties extends ComfyWidgetProperties { + values: string[] + + /* JS Function body that takes a parameter named "value" as a parameter and returns the label for each combo entry */ + convertValueToLabelCode: string +} + +export default class ComfyComboNode extends ComfyWidgetNode { + override properties: ComfyComboProperties = { + tags: [], + defaultValue: "A", + values: ["A", "B", "C", "D"], + convertValueToLabelCode: "" + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "string" }, + { name: "changed", type: BuiltInSlotType.EVENT } + ] + } + + override svelteComponentType = ComboWidget + override defaultValue = "A"; + override saveUserState = false; + + // True if at least one combo box refresh has taken place + // Wait until the initial graph load for combo to be valid. + firstLoad: Writable; + valuesForCombo: Writable; // Changed when the combo box has values. + + constructor(name?: string) { + super(name, "A") + this.firstLoad = writable(false) + this.valuesForCombo = writable(null) + } + + override onPropertyChanged(property: any, value: any) { + if (property === "values" || property === "convertValueToLabelCode") { + // this.formatValues(this.properties.values) + } + } + + formatValues(values: string[]) { + if (values == null) + return; + + this.properties.values = values; + + let formatter: any; + if (this.properties.convertValueToLabelCode) + formatter = new Function("value", this.properties.convertValueToLabelCode) as (v: string) => string; + else + formatter = (value: any) => `${value}`; + + let valuesForCombo = [] + + try { + valuesForCombo = this.properties.values.map((value, index) => { + return { + value, + label: formatter(value), + index + } + }) + } + catch (err) { + console.error("Failed formatting!", err) + valuesForCombo = this.properties.values.map((value, index) => { + return { + value, + label: `${value}`, + index + } + }) + } + + this.firstLoad.set(true) + this.valuesForCombo.set(valuesForCombo); + } + + onConnectOutput( + outputIndex: number, + inputType: INodeInputSlot["type"], + input: INodeInputSlot, + inputNode: LGraphNode, + inputIndex: number + ): boolean { + if (!super.onConnectOutput(outputIndex, inputType, input, inputNode, inputIndex)) + return false; + + const thisProps = this.properties; + if (!("config" in input)) + return true; + + const comfyInput = input as IComfyInputSlot; + const otherProps = comfyInput.config; + + // Ensure combo options match + if (!(otherProps.values instanceof Array)) + return false; + if (thisProps.values.find((v, i) => otherProps.values.indexOf(v) === -1)) + return false; + + return true; + } + + override parseValue(value: any): string { + if (typeof value !== "string" || this.properties.values.indexOf(value) === -1) + return this.properties.values[0] + return value + } + + override clampOneConfig(input: IComfyInputSlot) { + if (!input.config.values) + this.setValue("") + else if (input.config.values.indexOf(this.properties.value) === -1) { + if (input.config.values.length === 0) + this.setValue("") + else + this.setValue(input.config.defaultValue || input.config.values[0]) + } + } + + override onSerialize(o: SerializedLGraphNode) { + super.onSerialize(o); + // TODO fix saving combo nodes with huge values lists + o.properties.values = [] + } + + override stripUserState(o: SerializedLGraphNode) { + super.stripUserState(o); + o.properties.values = [] + } +} + +LiteGraph.registerNodeType({ + class: ComfyComboNode, + title: "UI.Combo", + desc: "Combo box outputting a string value", + type: "ui/combo" +}) diff --git a/src/lib/nodes/widgets/ComfyGalleryNode.ts b/src/lib/nodes/widgets/ComfyGalleryNode.ts new file mode 100644 index 0000000..d0b1f5b --- /dev/null +++ b/src/lib/nodes/widgets/ComfyGalleryNode.ts @@ -0,0 +1,99 @@ +import { parseWhateverIntoImageMetadata, type ComfyBoxImageMetadata, type ComfyUploadImageType } from "$lib/utils"; +import { BuiltInSlotType, LiteGraph, type IComboWidget, type ITextWidget, type PropertyLayout, type SlotLayout } from "@litegraph-ts/core"; +import { get } from "svelte/store"; + +import GalleryWidget from "$lib/widgets/GalleryWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + +export interface ComfyGalleryProperties extends ComfyWidgetProperties { + index: number, + updateMode: "replace" | "append", +} + +export default class ComfyGalleryNode extends ComfyWidgetNode { + override properties: ComfyGalleryProperties = { + tags: [], + defaultValue: [], + index: 0, + updateMode: "replace", + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "images", type: "OUTPUT" }, + { name: "store", type: BuiltInSlotType.ACTION, options: { color_off: "rebeccapurple", color_on: "rebeccapurple" } } + ], + outputs: [ + { name: "images", type: "COMFYBOX_IMAGES" }, + { name: "selected_index", type: "number" }, + ] + } + + static propertyLayout: PropertyLayout = [ + { name: "updateMode", defaultValue: "replace", type: "enum", options: { values: ["replace", "append"] } } + ] + + override svelteComponentType = GalleryWidget + override defaultValue = [] + override saveUserState = false; + override outputSlotName = null; + override changedEventName = null; + + selectedFilename: string | null = null; + + selectedIndexWidget: ITextWidget; + modeWidget: IComboWidget; + + constructor(name?: string) { + super(name, []) + this.selectedIndexWidget = this.addWidget("text", "Selected", String(this.properties.index), "index") + this.selectedIndexWidget.disabled = true; + this.modeWidget = this.addWidget("combo", "Mode", this.properties.updateMode, null, { property: "updateMode", values: ["replace", "append"] }) + } + + override onPropertyChanged(property: any, value: any) { + if (property === "updateMode") { + this.modeWidget.value = value; + } + } + + override onExecute() { + this.setOutputData(0, get(this.value)) + this.setOutputData(1, this.properties.index) + } + + override onAction(action: any, param: any, options: { action_call?: string }) { + super.onAction(action, param, options) + } + + override formatValue(value: ComfyBoxImageMetadata[] | null): string { + return `Images: ${value?.length || 0}` + } + + override parseValue(param: any): ComfyBoxImageMetadata[] { + const meta = parseWhateverIntoImageMetadata(param) || []; + + console.debug("[ComfyGalleryNode] Received output!", param) + + if (this.properties.updateMode === "append") { + const currentValue = get(this.value) + return currentValue.concat(meta) + } + else { + return meta; + } + } + + override setValue(value: any, noChangedEvent: boolean = false) { + super.setValue(value, noChangedEvent) + this.setProperty("index", null) + } +} + +LiteGraph.registerNodeType({ + class: ComfyGalleryNode, + title: "UI.Gallery", + desc: "Gallery that shows most recent outputs", + type: "ui/gallery" +}) diff --git a/src/lib/nodes/widgets/ComfyImageEditorNode.ts b/src/lib/nodes/widgets/ComfyImageEditorNode.ts new file mode 100644 index 0000000..3adbdbb --- /dev/null +++ b/src/lib/nodes/widgets/ComfyImageEditorNode.ts @@ -0,0 +1,51 @@ +import { parseWhateverIntoImageMetadata, type ComfyBoxImageMetadata } from "$lib/utils"; +import type { FileData as GradioFileData } from "@gradio/upload"; +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import ImageUploadWidget from "$lib/widgets/ImageUploadWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + +export interface ComfyImageUploadNodeProperties extends ComfyWidgetProperties { +} + +export default class ComfyImageUploadNode extends ComfyWidgetNode { + properties: ComfyImageUploadNodeProperties = { + defaultValue: [], + tags: [], + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "images", type: "COMFYBOX_IMAGES" }, // TODO support batches + { name: "changed", type: BuiltInSlotType.EVENT }, + ] + } + + override svelteComponentType = ImageUploadWidget; + override defaultValue = []; + override storeActionName = "store"; + override saveUserState = false; + + constructor(name?: string) { + super(name, []) + } + + override parseValue(value: any): ComfyBoxImageMetadata[] { + return parseWhateverIntoImageMetadata(value) || []; + } + + override formatValue(value: GradioFileData[]): string { + return `Images: ${value?.length || 0}` + } +} + +LiteGraph.registerNodeType({ + class: ComfyImageUploadNode, + title: "UI.ImageUpload", + desc: "Widget that lets you upload and edit a multi-layered image. Can also act like a standalone image uploader.", + type: "ui/image_upload" +}) diff --git a/src/lib/nodes/widgets/ComfyNumberNode.ts b/src/lib/nodes/widgets/ComfyNumberNode.ts new file mode 100644 index 0000000..0d6c7af --- /dev/null +++ b/src/lib/nodes/widgets/ComfyNumberNode.ts @@ -0,0 +1,69 @@ +import type IComfyInputSlot from "$lib/IComfyInputSlot"; +import { clamp } from "$lib/utils"; +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import RangeWidget from "$lib/widgets/RangeWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + +export interface ComfyNumberProperties extends ComfyWidgetProperties { + min: number, + max: number, + step: number, + precision: number +} + +export default class ComfyNumberNode extends ComfyWidgetNode { + override properties: ComfyNumberProperties = { + tags: [], + defaultValue: 0, + min: 0, + max: 10, + step: 1, + precision: 1 + } + + override svelteComponentType = RangeWidget + override defaultValue = 0; + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "number" }, + { name: "changed", type: BuiltInSlotType.EVENT }, + ] + } + + override outputProperties = [ + { name: "min", type: "number" }, + { name: "max", type: "number" }, + { name: "step", type: "number" }, + { name: "precision", type: "number" }, + ] + + constructor(name?: string) { + super(name, 0) + } + + override parseValue(value: any): number { + if (typeof value !== "number") + return this.properties.min; + return clamp(value, this.properties.min, this.properties.max) + } + + override clampOneConfig(input: IComfyInputSlot) { + // this.setProperty("min", clamp(this.properties.min, input.config.min, input.config.max)) + // this.setProperty("max", clamp(this.properties.max, input.config.max, input.config.min)) + // this.setProperty("step", Math.min(this.properties.step, input.config.step)) + this.setValue(this.properties.defaultValue) + } +} + +LiteGraph.registerNodeType({ + class: ComfyNumberNode, + title: "UI.Number", + desc: "Displays a number, by default in a slider format.", + type: "ui/number" +}) diff --git a/src/lib/nodes/widgets/ComfyRadioNode.ts b/src/lib/nodes/widgets/ComfyRadioNode.ts new file mode 100644 index 0000000..aee9f86 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyRadioNode.ts @@ -0,0 +1,82 @@ +import { clamp } from "$lib/utils"; +import { BuiltInSlotType, LiteGraph, type INumberWidget, type SlotLayout } from "@litegraph-ts/core"; +import { get } from "svelte/store"; + +import RadioWidget from "$lib/widgets/RadioWidget.svelte"; +import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; +import ComfyWidgetNode from "./ComfyWidgetNode"; + + +export interface ComfyRadioProperties extends ComfyWidgetProperties { + choices: string[] +} + +export default class ComfyRadioNode extends ComfyWidgetNode { + override properties: ComfyRadioProperties = { + tags: [], + choices: ["Choice A", "Choice B", "Choice C"], + defaultValue: "Choice A", + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "string" }, + { name: "index", type: "number" }, + { name: "changed", type: BuiltInSlotType.EVENT }, + ] + } + + override svelteComponentType = RadioWidget; + override defaultValue = ""; + + indexWidget: INumberWidget; + + index = 0; + + constructor(name?: string) { + super(name, "Choice A") + this.indexWidget = this.addWidget("number", "Index", this.index) + this.indexWidget.disabled = true; + } + + override onExecute(param: any, options: object) { + super.onExecute(param, options); + this.setOutputData(1, this.index) + } + + override setValue(value: string, noChangedEvent: boolean = false) { + super.setValue(value, noChangedEvent) + + value = get(this.value); + + const index = this.properties.choices.indexOf(value) + if (index === -1) + return; + + this.index = index; + this.indexWidget.value = index; + this.setOutputData(1, this.index) + } + + override parseValue(param: any): string { + if (typeof param === "string") { + if (this.properties.choices.indexOf(param) === -1) + return this.properties.choices[0] + return param + } + else { + const index = clamp(parseInt(param), 0, this.properties.choices.length - 1) + return this.properties.choices[index] || this.properties.defaultValue + } + } +} + +LiteGraph.registerNodeType({ + class: ComfyRadioNode, + title: "UI.Radio", + desc: "Radio that outputs a string and index", + type: "ui/radio" +}) diff --git a/src/lib/nodes/widgets/ComfyTextNode.ts b/src/lib/nodes/widgets/ComfyTextNode.ts new file mode 100644 index 0000000..4c62642 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyTextNode.ts @@ -0,0 +1,44 @@ +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import TextWidget from "$lib/widgets/TextWidget.svelte"; +import ComfyWidgetNode, { type ComfyWidgetProperties } from "./ComfyWidgetNode"; + +export interface ComfyTextProperties extends ComfyWidgetProperties { + multiline: boolean; +} + +export default class ComfyTextNode extends ComfyWidgetNode { + override properties: ComfyTextProperties = { + tags: [], + defaultValue: "", + multiline: false + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION } + ], + outputs: [ + { name: "value", type: "string" }, + { name: "changed", type: BuiltInSlotType.EVENT } + ] + } + + override svelteComponentType = TextWidget + override defaultValue = ""; + + constructor(name?: string) { + super(name, "") + } + + override parseValue(value: any): string { + return `${value}` + } +} + +LiteGraph.registerNodeType({ + class: ComfyTextNode, + title: "UI.Text", + desc: "Textbox outputting a string value", + type: "ui/text" +}) diff --git a/src/lib/nodes/widgets/ComfyWidgetNode.ts b/src/lib/nodes/widgets/ComfyWidgetNode.ts new file mode 100644 index 0000000..56709b6 --- /dev/null +++ b/src/lib/nodes/widgets/ComfyWidgetNode.ts @@ -0,0 +1,329 @@ +import type IComfyInputSlot from "$lib/IComfyInputSlot"; +import layoutState from "$lib/stores/layoutState"; +import { range } from "$lib/utils"; +import { LConnectionKind, LGraphCanvas, LLink, LiteGraph, NodeMode, type INodeInputSlot, type INodeOutputSlot, type ITextWidget, type LGraphNode, type SerializedLGraphNode, type Vector2 } from "@litegraph-ts/core"; +import { Watch } from "@litegraph-ts/nodes-basic"; +import type { SvelteComponentDev } from "svelte/internal"; +import { get, writable, type Unsubscriber, type Writable } from "svelte/store"; + +import type { ComfyNodeID } from "$lib/api"; +import type { ComfyGraphNodeProperties } from "../ComfyGraphNode"; +import ComfyGraphNode from "../ComfyGraphNode"; + +export type AutoConfigOptions = { + includeProperties?: Set | null, + setDefaultValue?: boolean + setWidgetTitle?: boolean +} + +/* + * NOTE: If you want to add a new widget but it has the same input/output type + * as another one of the existing widgets, best to create a new "variant" of + * that widget instead. + * + * - Go to layoutState, look for `ALL_ATTRIBUTES,` insert or find a "variant" + * attribute and set `validNodeTypes` to the type of the litegraph node + * - Add a new entry in the `values` array, like "knob" or "dial" for ComfySliderWidget + * - Add an {#if widget.attrs.variant === <...>} statement in the existing Svelte component + * + * Also, BEWARE of calling setOutputData() and triggerSlot() on the same frame! + * You will have to either implement an internal delay on the event triggering + * or use an Event Delay node to ensure the output slot data can propagate to + * the rest of the graph first (see `delayChangedEvent` for details) + */ + +export interface ComfyWidgetProperties extends ComfyGraphNodeProperties { + defaultValue: any +} + +/* + * A node that is tied to a UI widget in the frontend. When the frontend's + * widget is changed, the value of the first output in the node is updated + * in the litegraph instance. + */ +export default abstract class ComfyWidgetNode extends ComfyGraphNode { + abstract properties: ComfyWidgetProperties; + + value: Writable + propsChanged: Writable = writable(0) // dummy to indicate if props changed + unsubscribe: Unsubscriber; + + /** Svelte class for the frontend logic */ + abstract svelteComponentType: typeof SvelteComponentDev + + /** If false, user manually set min/max/step, and should not be autoinherited from connected input */ + autoConfig: boolean = true; + + copyFromInputLink: boolean = true; + + /** + * If true wait until next frame update to trigger the changed event. + * Reason is, if the event is triggered immediately then other stuff that wants to run + * their own onExecute on the output value won't have completed yet. + */ + delayChangedEvent: boolean = true; + + private _aboutToChange: number = 0; + private _aboutToChangeValue: any = null; + private _noChangedEvent: boolean = false; + + abstract defaultValue: T; + + /** Names of properties to add as inputs */ + // shownInputProperties: string[] = [] + + /** Names of properties to add as outputs */ + private shownOutputProperties: Record = {} + outputProperties: { name: string, type: string }[] = [] + + override isBackendNode = false; + override serialize_widgets = true; + + storeActionName: string | null = "store"; + + // output slots + outputSlotName: string | null = "value"; + changedEventName: string | null = "changed"; + + displayWidget: ITextWidget; + + override size: Vector2 = [60, 40]; + + constructor(name: string, value: T) { + const color = LGraphCanvas.node_colors["blue"] + super(name) + this.value = writable(value) + this.color ||= color.color + this.bgColor ||= color.bgColor + this.displayWidget = this.addWidget( + "text", + "Value", + "" + ); + this.displayWidget.disabled = true; // prevent editing + this.unsubscribe = this.value.subscribe(this.onValueUpdated.bind(this)) + } + + addPropertyAsOutput(propertyName: string, type: string) { + if (this.shownOutputProperties["@" + propertyName]) + return; + + if (!(propertyName in this.properties)) { + throw `No property named ${propertyName} found!` + } + + this.shownOutputProperties["@" + propertyName] = { type, index: this.outputs.length } + this.addOutput("@" + propertyName, type) + } + + formatValue(value: any): string { + return Watch.toString(value) + } + + override changeMode(modeTo: NodeMode): boolean { + const result = super.changeMode(modeTo); + this.notifyPropsChanged(); + return result; + } + + private onValueUpdated(value: any) { + // console.debug("[Widget] valueUpdated", this, value) + this.displayWidget.value = this.formatValue(value) + + if (this.outputSlotName !== null) { + const outputIndex = this.findOutputSlotIndexByName(this.outputSlotName) + if (outputIndex !== -1) + this.setOutputData(outputIndex, get(this.value)) + } + + if (this.changedEventName !== null && !this._noChangedEvent) { + if (!this.delayChangedEvent) + this.triggerChangeEvent(get(this.value)) + else { + // console.debug("[Widget] queueChangeEvent", this, value) + this._aboutToChange = 2; // wait 1.5-2 frames, in case we're already in the middle of executing the graph + this._aboutToChangeValue = get(this.value); + } + } + this._noChangedEvent = false; + } + + private triggerChangeEvent(value: any) { + // console.debug("[Widget] trigger changed", this, value) + this.trigger(this.changedEventName, value) + } + + parseValue(value: any): T { return value as T }; + + getValue(): T { + return get(this.value); + } + + setValue(value: any, noChangedEvent: boolean = false) { + if (noChangedEvent) + this._noChangedEvent = true; + + const parsed = this.parseValue(value) + this.value.set(parsed) + + // In case value.set() does not trigger onValueUpdated, we need to reset + // the counter here also. + this._noChangedEvent = false; + } + + override onPropertyChanged(property: string, value: any, prevValue?: any) { + if (this.shownOutputProperties != null) { + const data = this.shownOutputProperties[property] + if (data) + this.setOutputData(data.index, value) + } + } + + /* + * Logic to run if this widget can be treated as output (slider, combo, text) + */ + override onExecute(param: any, options: object) { + if (this.outputSlotName != null) { + const outputIndex = this.findOutputSlotIndexByName(this.outputSlotName) + if (outputIndex !== -1) + this.setOutputData(outputIndex, get(this.value)) + } + for (const propName in this.shownOutputProperties) { + const data = this.shownOutputProperties[propName] + this.setOutputData(data.index, this.properties[propName]) + } + + // Fire a pending change event after one full step of the graph has + // finished processing + if (this._aboutToChange > 0) { + this._aboutToChange -= 1 + if (this._aboutToChange <= 0) { + const value = this._aboutToChangeValue; + this._aboutToChange = 0; + this._aboutToChangeValue = null; + this.triggerChangeEvent(value); + } + } + } + + override onAction(action: any, param: any, options: { action_call?: string }) { + if (action === this.storeActionName) { + let noChangedEvent = false; + let value = param; + if (param != null && typeof param === "object" && "value" in param) { + value = param.value + if ("noChangedEvent" in param) + noChangedEvent = Boolean(param.noChangedEvent) + } + this.setValue(value, noChangedEvent) + } + } + + onConnectOutput( + outputIndex: number, + inputType: INodeInputSlot["type"], + input: INodeInputSlot, + inputNode: LGraphNode, + inputIndex: number + ): boolean { + const anyConnected = range(this.outputs.length).some(i => this.getOutputLinks(i).length > 0); + + if (this.autoConfig && "config" in input && !anyConnected && (input as IComfyInputSlot).widgetNodeType === this.type) { + this.doAutoConfig(input as IComfyInputSlot) + } + + return true; + } + + doAutoConfig(input: IComfyInputSlot, options: AutoConfigOptions = { setDefaultValue: true, setWidgetTitle: true }) { + // Copy properties from default config in input slot + const comfyInput = input as IComfyInputSlot; + for (const key in comfyInput.config) { + if (options.includeProperties == null || options.includeProperties.has(key)) + this.setProperty(key, comfyInput.config[key]) + } + + if (options.setDefaultValue) { + if ("defaultValue" in this.properties) + this.setValue(this.properties.defaultValue) + } + + if (options.setWidgetTitle) { + const widget = layoutState.findLayoutForNode(this.id as ComfyNodeID) + if (widget && input.name !== "") { + widget.attrs.title = input.name; + } + } + + // console.debug("Property copy", input, this.properties) + + this.setValue(get(this.value)) + + this.onAutoConfig(input); + + this.notifyPropsChanged(); + } + + onAutoConfig(input: IComfyInputSlot) { + } + + notifyPropsChanged() { + const layoutEntry = layoutState.findLayoutEntryForNode(this.id as ComfyNodeID) + if (layoutEntry && layoutEntry.parent) { + layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1) + } + // console.debug("propsChanged", this) + this.propsChanged.set(get(this.propsChanged) + 1) + + } + + override onConnectionsChange( + type: LConnectionKind, + slotIndex: number, + isConnected: boolean, + link: LLink, + ioSlot: (INodeOutputSlot | INodeInputSlot) + ): void { + super.onConnectionsChange(type, slotIndex, isConnected, link, ioSlot); + this.clampConfig(); + } + + clampConfig() { + let changed = false; + for (const link of this.getOutputLinks(0)) { + if (link) { // can be undefined if the link is removed + const node = this.graph._nodes_by_id[link.target_id] + if (node) { + const input = node.inputs[link.target_slot] + if (input && "config" in input) { + this.clampOneConfig(input as IComfyInputSlot) + changed = true; + } + } + } + } + + // Force reactivity change so the frontend can be updated with the new props + this.notifyPropsChanged(); + } + + clampOneConfig(input: IComfyInputSlot) { } + + override onSerialize(o: SerializedLGraphNode) { + (o as any).comfyValue = get(this.value); + (o as any).shownOutputProperties = this.shownOutputProperties + super.onSerialize(o); + } + + override onConfigure(o: SerializedLGraphNode) { + const value = (o as any).comfyValue || LiteGraph.cloneObject(this.defaultValue); + this.value.set(value); + this.shownOutputProperties = (o as any).shownOutputProperties; + } + + override stripUserState(o: SerializedLGraphNode) { + super.stripUserState(o); + (o as any).comfyValue = this.defaultValue; + o.properties.defaultValue = null; + } +} diff --git a/src/lib/nodes/widgets/ImageUploadWidget.svelte b/src/lib/nodes/widgets/ImageUploadWidget.svelte new file mode 100644 index 0000000..ef82b4b --- /dev/null +++ b/src/lib/nodes/widgets/ImageUploadWidget.svelte @@ -0,0 +1,344 @@ + + +
+ {#if widget.attrs.variant === "fileUpload" || isMobile} + + {:else} +
+ + +
+
+ +
+
+
+
+ + +
+ + + {#if !$nodeValue || $nodeValue.length === 0} + + + +
+ +
+ {#if uploadError} +
+ Upload error: {uploadError} +
+ {/if} +
+ + + + +
+ {:else} + + +
+ +
+ {#if uploadError} +
+ Upload error: {uploadError} +
+ {/if} +
+ {/if} +
+
+ {/if} +
+ + diff --git a/src/lib/nodes/widgets/index.ts b/src/lib/nodes/widgets/index.ts new file mode 100644 index 0000000..73f3264 --- /dev/null +++ b/src/lib/nodes/widgets/index.ts @@ -0,0 +1,9 @@ +export { default as ComfyWidgetNode } from "./ComfyWidgetNode" +export { default as ComfyButtonNode } from "./ComfyButtonNode" +export { default as ComfyCheckboxNode } from "./ComfyCheckboxNode" +export { default as ComfyComboNode } from "./ComfyComboNode" +export { default as ComfyGalleryNode } from "./ComfyGalleryNode" +export { default as ComfyImageEditorNode } from "./ComfyImageEditorNode" +export { default as ComfyRadioNode } from "./ComfyRadioNode" +export { default as ComfyNumberNode } from "./ComfyNumberNode" +export { default as ComfyTextNode } from "./ComfyTextNode" From 0626baba2e4cf22948dd4b44ff6706cb936ebb10 Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Tue, 16 May 2023 21:08:43 -0500 Subject: [PATCH 15/38] Light up refreshed combo boxes --- src/lib/components/BlockContainer.svelte | 2 +- src/lib/components/ComfyApp.ts | 2 +- src/lib/nodes/widgets/ComfyComboNode.ts | 9 +++++++-- src/lib/widgets/ComboWidget.svelte | 21 ++++++++------------- src/scss/global.scss | 15 ++++----------- 5 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/lib/components/BlockContainer.svelte b/src/lib/components/BlockContainer.svelte index 05d5882..9d14cba 100644 --- a/src/lib/components/BlockContainer.svelte +++ b/src/lib/components/BlockContainer.svelte @@ -128,7 +128,7 @@ &.empty { border-width: 3px; border-color: var(--color-grey-400); - border-radius: var(--block-radius); + border-radius: 0; background: var(--color-grey-300); min-height: 100px; border-style: dashed; diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index aff7964..f2f3be7 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -807,7 +807,7 @@ export default class ComfyApp { console.debug("[ComfyApp] Reconfiguring combo widget", backendNode.type, "=>", comboNode.type, rawValues.length) comboNode.doAutoConfig(comfyInput, { includeProperties: new Set(["values"]), setWidgetTitle: false }) - comboNode.formatValues(rawValues as string[]) + comboNode.formatValues(rawValues as string[], true) if (!rawValues?.includes(get(comboNode.value))) { comboNode.setValue(rawValues[0]) } diff --git a/src/lib/nodes/widgets/ComfyComboNode.ts b/src/lib/nodes/widgets/ComfyComboNode.ts index e44a800..fd21274 100644 --- a/src/lib/nodes/widgets/ComfyComboNode.ts +++ b/src/lib/nodes/widgets/ComfyComboNode.ts @@ -1,6 +1,6 @@ import type IComfyInputSlot from "$lib/IComfyInputSlot"; import { BuiltInSlotType, LiteGraph, type INodeInputSlot, type LGraphNode, type SerializedLGraphNode, type SlotLayout } from "@litegraph-ts/core"; -import { writable, type Writable } from "svelte/store"; +import { get, writable, type Writable } from "svelte/store"; import ComboWidget from "$lib/widgets/ComboWidget.svelte"; import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; @@ -39,11 +39,13 @@ export default class ComfyComboNode extends ComfyWidgetNode { // True if at least one combo box refresh has taken place // Wait until the initial graph load for combo to be valid. firstLoad: Writable; + lightUp: Writable; valuesForCombo: Writable; // Changed when the combo box has values. constructor(name?: string) { super(name, "A") this.firstLoad = writable(false) + this.lightUp = writable(true) this.valuesForCombo = writable(null) } @@ -53,11 +55,14 @@ export default class ComfyComboNode extends ComfyWidgetNode { } } - formatValues(values: string[]) { + formatValues(values: string[], lightUp: boolean = false) { if (values == null) return; + const changed = this.properties.values != values; this.properties.values = values; + if (lightUp && get(this.firstLoad) && changed) + this.lightUp.set(true) let formatter: any; if (this.properties.convertValueToLabelCode) diff --git a/src/lib/widgets/ComboWidget.svelte b/src/lib/widgets/ComboWidget.svelte index d7dc56d..ec384a0 100644 --- a/src/lib/widgets/ComboWidget.svelte +++ b/src/lib/widgets/ComboWidget.svelte @@ -13,6 +13,7 @@ let node: ComfyComboNode | null = null; let nodeValue: Writable | null = null; let propsChanged: Writable | null = null; + let lightUp: Writable = writable(false); let valuesForCombo: Writable | null = null; let lastConfigured: any = null; let option: any = null; @@ -40,6 +41,7 @@ node = widget.node as ComfyComboNode nodeValue = node.value; propsChanged = node.propsChanged; + lightUp = node.lightUp; valuesForCombo = node.valuesForCombo; lastConfigured = $valuesForCombo } @@ -52,16 +54,8 @@ activeIndex = values.findIndex(v => v.value === value); } - $: $valuesForCombo != lastConfigured && flashOnRefreshed(); - let lightUp = false; - - function flashOnRefreshed() { - lastConfigured = $valuesForCombo - if (lastConfigured != null) { - lightUp = true; - setTimeout(() => (lightUp = false), 1000); - } - } + $: if ($lightUp) + setTimeout(() => ($lightUp = false), 1000); function getLinkValue() { if (!node) @@ -137,7 +131,7 @@ -
+
{#key $valuesForCombo} {#if node !== null && nodeValue !== null} {#if $valuesForCombo == null} @@ -172,10 +166,11 @@
{#if filteredItems.length > 0} {@const itemSize = isMobile ? 50 : 25} + {@const itemsToShow = isMobile ? 10 : 30} Date: Tue, 16 May 2023 23:03:49 -0500 Subject: [PATCH 16/38] Work --- litegraph | 2 +- src/lib/components/BlockContainer.svelte | 16 ++-- src/lib/components/ComfyApp.ts | 2 +- src/lib/components/ComfyProperties.svelte | 10 +-- src/lib/components/ComfyUIPane.svelte | 13 +++- src/lib/components/Container.svelte | 6 +- src/lib/components/WidgetContainer.svelte | 4 +- src/lib/nodes/ComfyActionNodes.ts | 4 +- src/lib/nodes/ComfyImageToFilepathNode.ts | 33 --------- src/lib/nodes/ComfyPickImageNode.ts | 74 ++++++++++++++++--- src/lib/nodes/index.ts | 1 - ...eEditorNode.ts => ComfyImageUploadNode.ts} | 0 src/lib/nodes/widgets/ComfyNumberNode.ts | 4 +- src/lib/nodes/widgets/ComfyWidgetNode.ts | 2 +- src/lib/nodes/widgets/index.ts | 2 +- src/lib/stores/layoutState.ts | 22 +++--- src/lib/utils.ts | 14 ++-- src/lib/widgets/RangeWidget.svelte | 0 src/lib/widgets/utils.ts | 16 +++- src/scss/global.scss | 4 + 20 files changed, 133 insertions(+), 96 deletions(-) delete mode 100644 src/lib/nodes/ComfyImageToFilepathNode.ts rename src/lib/nodes/widgets/{ComfyImageEditorNode.ts => ComfyImageUploadNode.ts} (100%) delete mode 100644 src/lib/widgets/RangeWidget.svelte diff --git a/litegraph b/litegraph index ddd1ef2..90204e2 160000 --- a/litegraph +++ b/litegraph @@ -1 +1 @@ -Subproject commit ddd1ef25fb86e3dc9cd5e5fa77beb25aa5c33ee1 +Subproject commit 90204e22e74feae2154242f68afc6ad178e9dff3 diff --git a/src/lib/components/BlockContainer.svelte b/src/lib/components/BlockContainer.svelte index 9d14cba..c8cf053 100644 --- a/src/lib/components/BlockContainer.svelte +++ b/src/lib/components/BlockContainer.svelte @@ -63,7 +63,7 @@ class:root-container={zIndex === 0} class:is-executing={container.isNodeExecuting} class:mobile={isMobile} - class:edit={edit}> + class:edit> {#if container.attrs.title && container.attrs.title !== ""}