From 0fedef30c025cdb62f3d963007287c3816219e2e Mon Sep 17 00:00:00 2001 From: space-nuko <24979496+space-nuko@users.noreply.github.com> Date: Wed, 24 May 2023 23:21:06 -0500 Subject: [PATCH] More template work & configure backend URL --- public/config.json | 7 + src/lib/ComfyBoxTemplate.ts | 23 +- src/lib/ComfyGraph.ts | 29 +- src/lib/ComfyGraphCanvas.ts | 15 +- src/lib/components/ComfyApp.ts | 45 +- src/lib/components/ComfyTemplates.svelte | 26 +- src/lib/components/GlobalModal.svelte | 3 +- src/lib/components/ImageUpload.svelte | 3 +- src/lib/components/utils.ts | 9 +- src/lib/nodes/ComfyGraphNode.ts | 8 +- src/lib/stores/configState.ts | 17 +- src/lib/stores/layoutStates.ts | 3 + src/lib/stores/modalState.ts | 2 +- src/lib/stores/uiState.ts | 4 +- src/lib/utils.ts | 1171 +++++++++--------- src/lib/widgets/ImageUploadWidget.svelte | 3 +- src/lib/widgets/TextWidgetCodeVariant.svelte | 19 +- 17 files changed, 764 insertions(+), 623 deletions(-) create mode 100644 public/config.json diff --git a/public/config.json b/public/config.json new file mode 100644 index 0000000..dfe2fce --- /dev/null +++ b/public/config.json @@ -0,0 +1,7 @@ +{ + "comfyUIHostname": "localhost", + "comfyUIPort": 8188, + "alwaysStripUserState": false, + "promptForWorkflowName": false, + "confirmWhenUnloadingUnsavedChanges": true +} diff --git a/src/lib/ComfyBoxTemplate.ts b/src/lib/ComfyBoxTemplate.ts index d6355ca..d3ec966 100644 --- a/src/lib/ComfyBoxTemplate.ts +++ b/src/lib/ComfyBoxTemplate.ts @@ -1,10 +1,11 @@ -import { Subgraph, type LGraphNode, type LLink, type SerializedLGraphNode, type SerializedLLink, LGraph, type NodeID, type UUID } from "@litegraph-ts/core" +import { Subgraph, type LGraphNode, type LLink, type SerializedLGraphNode, type SerializedLLink, LGraph, type NodeID, type UUID, type Vector2 } from "@litegraph-ts/core" import layoutStates, { isComfyWidgetNode, type ContainerLayout, type SerializedDragEntry, type WidgetLayout, type DragItemID, type WritableLayoutStateStore, type DragItemEntry, type SerializedLayoutState } from "./stores/layoutStates" import type { ComfyWidgetNode } from "./nodes/widgets" import type ComfyGraphCanvas from "./ComfyGraphCanvas" import C2S from "canvas-to-svg"; -import { download } from "./utils"; +import { calcNodesBoundingBox, download } from "./utils"; import { v4 as uuidv4 } from "uuid"; +import uiState from "./stores/uiState"; /* * In ComfyBox a template contains a subset of nodes in the graph and the set of @@ -270,6 +271,19 @@ export function embedTemplateInSvg(template: SerializedComfyBoxTemplate): string return svg } +/* + * Moves nodes so their origin is at (0, 0) + */ +function relocateNodes(nodes: SerializedLGraphNode[]): SerializedLGraphNode[] { + let [min_x, min_y, max_x, max_y] = calcNodesBoundingBox(nodes); + + for (const node of nodes) { + node.pos = [node.pos[0] - min_x, node.pos[1] - min_y]; + } + + return nodes; +} + function pruneDetachedLinks(nodes: SerializedLGraphNode[], links: SerializedTemplateLink[]): [SerializedLGraphNode[], SerializedTemplateLink[]] { const nodeIds = new Set(nodes.map(n => n.id)); @@ -313,11 +327,16 @@ export function serializeTemplate(canvas: ComfyGraphCanvas, template: ComfyBoxTe if (layoutState == null) throw "Couldn't find layout for template being serialized!" + uiState.update(s => { s.forceSaveUserState = false; return s; }); + const metadata = template.metadata; let nodes = template.nodes.map(n => n.serialize()); let links = template.links.map(convLinkForTemplate); const layout = layoutState.serializeAtRoot(template.container.dragItem.id); + uiState.update(s => { s.forceSaveUserState = null; return s; }); + + nodes = relocateNodes(nodes); [nodes, links] = pruneDetachedLinks(nodes, links); const svg = renderSvg(canvas, graph, TEMPLATE_SVG_PADDING); diff --git a/src/lib/ComfyGraph.ts b/src/lib/ComfyGraph.ts index cf6cb34..49f03ff 100644 --- a/src/lib/ComfyGraph.ts +++ b/src/lib/ComfyGraph.ts @@ -16,30 +16,6 @@ import workflowState from "./stores/workflowState"; import type { SerializedComfyBoxTemplate } from "./ComfyBoxTemplate"; import { v4 as uuidv4 } from "uuid" -function calculateMinPosOfNodes(nodes: SerializedLGraphNode[]): Vector2 { - var posMin: Vector2 = [0, 0] - var posMinIndexes: [number, number] | null = null; - - for (var i = 0; i < nodes.length; ++i) { - if (posMin) { - if (posMin[0] > nodes[i].pos[0]) { - posMin[0] = nodes[i].pos[0]; - posMinIndexes[0] = i; - } - if (posMin[1] > nodes[i].pos[1]) { - posMin[1] = nodes[i].pos[1]; - posMinIndexes[1] = i; - } - } - else { - posMin = [nodes[i].pos[0], nodes[i].pos[1]]; - posMinIndexes = [i, i]; - } - } - - return posMin; -} - type ComfyGraphEvents = { configured: (graph: LGraph) => void nodeAdded: (node: LGraphNode) => void @@ -250,10 +226,11 @@ export default class ComfyGraph extends LGraph { /* * Inserts a template. - * Layout deserialization must be handled afterwards! + * Layout deserialization must be handled afterwards. + * NOTE: Modifies the template in-place, be sure you cloned it beforehand! */ insertTemplate(template: SerializedComfyBoxTemplate, pos: Vector2): Record { - const minPos = calculateMinPosOfNodes(template.nodes); + const minPos = [0, 0] const templateNodeIDToNewNode: Record = {} diff --git a/src/lib/ComfyGraphCanvas.ts b/src/lib/ComfyGraphCanvas.ts index c5738dd..8dd4b11 100644 --- a/src/lib/ComfyGraphCanvas.ts +++ b/src/lib/ComfyGraphCanvas.ts @@ -9,6 +9,7 @@ import selectionState from "./stores/selectionState"; import templateState from "./stores/templateState"; import { createTemplate, type ComfyBoxTemplate, serializeTemplate, type SerializedComfyBoxTemplate } from "./ComfyBoxTemplate"; import notify from "./notify"; +import { calcNodesBoundingBox } from "./utils"; export type SerializedGraphCanvasState = { offset: Vector2, @@ -448,14 +449,24 @@ export default class ComfyGraphCanvas extends LGraphCanvas { insertTemplate(template: SerializedComfyBoxTemplate, pos: Vector2, container: ContainerLayout, containerIndex: number): [LGraphNode[], IDragItem] { const comfyGraph = this.graph as ComfyGraph; + let [min_x, min_y, max_x, max_y] = calcNodesBoundingBox(template.nodes); + + const width = max_x - min_x + const height = max_y - min_y + + pos[0] -= width / 2 + pos[1] -= height / 2 + const layout = comfyGraph.layout; if (layout == null) { console.error("[ComfyGraphCanvas] graph has no layout!", comfyGraph) return; } - const nodeMapping = comfyGraph.insertTemplate(template, pos); - const templateLayoutRoot = layout.insertTemplate(template, comfyGraph, nodeMapping, container, containerIndex); + // The following operations modify the template in-place, so be sure it's been cloned first + const cloned = LiteGraph.cloneObject(template) + const nodeMapping = comfyGraph.insertTemplate(cloned, pos); + const templateLayoutRoot = layout.insertTemplate(cloned, comfyGraph, nodeMapping, container, containerIndex); this.selectNodes(Object.values(nodeMapping).filter(n => n.graph === this.graph)); diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index 9d9ce64..ce7a9bc 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -22,7 +22,7 @@ import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; import { ComfyComboNode } from "$lib/nodes/widgets"; import notify from "$lib/notify"; import parseA1111, { type A1111ParsedInfotext } from "$lib/parseA1111"; -import configState from "$lib/stores/configState"; +import configState, { type ConfigState } from "$lib/stores/configState"; import layoutStates, { defaultWorkflowAttributes, type SerializedLayoutState } from "$lib/stores/layoutStates"; import modalState from "$lib/stores/modalState"; import queueState from "$lib/stores/queueState"; @@ -192,6 +192,11 @@ export default class ComfyApp { return; } + await this.loadConfig(); + + this.api.hostname = get(configState).comfyUIHostname + this.api.port = get(configState).comfyUIPort + this.setupColorScheme() this.rootEl = document.getElementById("app-root") as HTMLDivElement; @@ -252,6 +257,20 @@ export default class ComfyApp { return Promise.resolve(); } + /* + * TODO + */ + async loadConfig() { + try { + const config = await fetch(`/config.json`); + const state = await config.json() as ConfigState; + configState.set(state); + } + catch (error) { + console.error(`Failed to load config`, error) + } + } + resizeCanvas() { this.canvasEl.width = this.canvasEl.parentElement.offsetWidth; this.canvasEl.height = this.canvasEl.parentElement.offsetHeight; @@ -282,7 +301,7 @@ export default class ComfyApp { saveStateToLocalStorage() { try { - uiState.update(s => { s.isSavingToLocalStorage = true; return s; }) + uiState.update(s => { s.forceSaveUserState = true; return s; }) const state = get(workflowState) const workflows = state.openedWorkflows const savedWorkflows = workflows.map(w => this.serialize(w)); @@ -298,7 +317,7 @@ export default class ComfyApp { notify(`Failed saving to local storage:\n${err}`, { type: "error" }) } finally { - uiState.update(s => { s.isSavingToLocalStorage = false; return s; }) + uiState.update(s => { s.forceSaveUserState = null; return s; }) } } @@ -1007,6 +1026,21 @@ export default class ComfyApp { reader.readAsText(file); } else if (file.type === "image/svg+xml" || file.name.endsWith(".svg")) { const templateAndSvg = await deserializeTemplateFromSVG(file); + + const importTemplate = () => { + try { + if (templateState.add(templateAndSvg)) { + notify("Template imported successfully!", { type: "success" }) + } + else { + notify("Template already exists in saved list.", { type: "warning" }) + } + } + catch (error) { + notify(`Error importing template: ${error}`, { type: "error", timeout: 10000 }) + } + } + modalState.pushModal({ title: "ComfyBox Template Preview", svelteComponent: EditTemplateModal, @@ -1017,6 +1051,11 @@ export default class ComfyApp { editable: false }, buttons: [ + { + name: "Import", + variant: "primary", + onClick: importTemplate + }, { name: "Close", variant: "secondary", diff --git a/src/lib/components/ComfyTemplates.svelte b/src/lib/components/ComfyTemplates.svelte index f9daa0d..3746a65 100644 --- a/src/lib/components/ComfyTemplates.svelte +++ b/src/lib/components/ComfyTemplates.svelte @@ -31,7 +31,6 @@ $: rebuildTemplates($templateState.templates); function rebuildTemplates(templates: SerializedComfyBoxTemplate[]) { - console.error("recreate"); _sorted = Array.from(templates).map(t => { return { type: "template", id: uuidv4(), template: t, attrs: {...defaultWidgetAttributes}, attrsChanged: writable(0) @@ -76,6 +75,9 @@ } function handleClick(layout: TemplateLayout) { + if ($uiState.uiUnlocked) + return; + const updateTemplate = (modal: ModalData) => { const state = get(modal.state); layout.template.metadata.title = state.name || layout.template.metadata.title @@ -94,6 +96,23 @@ } } + const deleteTemplate = (modal: ModalData) => { + if (!confirm("Are you sure you want to delete this template?")) + return false; + + try { + if (templateState.remove(layout.template.id)) { + notify("Template deleted!", { type: "success" }) + } + else { + notify("Failed to delete template: not saved to local storage.", { type: "warning" }) + } + } + catch (error) { + notify(`Failed to delete template: ${error}`, { type: "error", timeout: 10000 }) + } + } + const downloadTemplate = (modal: ModalData) => { updateTemplate(modal); const svg = embedTemplateInSvg(layout.template); @@ -120,6 +139,11 @@ onClick: downloadTemplate, closeOnClick: false }, + { + name: "Delete", + variant: "secondary", + onClick: deleteTemplate + }, { name: "Close", variant: "secondary", diff --git a/src/lib/components/GlobalModal.svelte b/src/lib/components/GlobalModal.svelte index 25e76bd..047c31d 100644 --- a/src/lib/components/GlobalModal.svelte +++ b/src/lib/components/GlobalModal.svelte @@ -13,7 +13,8 @@ } function onButtonClicked(modal: ModalData, button: ModalButton, closeDialog: Function) { - button.onClick(modal); + if (button.onClick(modal) === false) + return if (button.closeOnClick !== false) { closeDialog() diff --git a/src/lib/components/ImageUpload.svelte b/src/lib/components/ImageUpload.svelte index c5ec11f..3ee9dc3 100644 --- a/src/lib/components/ImageUpload.svelte +++ b/src/lib/components/ImageUpload.svelte @@ -2,6 +2,7 @@ import UploadText from "$lib/components/gradio/app/UploadText.svelte"; import type { ComfyImageLocation } from "$lib/nodes/ComfyWidgetNodes"; import notify from "$lib/notify"; + import configState from "$lib/stores/configState"; import { convertComfyOutputEntryToGradio, convertComfyOutputToComfyURL, type ComfyUploadImageAPIResponse } from "$lib/utils"; import { Block, BlockLabel } from "@gradio/atoms"; import { File as FileIcon } from "@gradio/icons"; @@ -68,7 +69,7 @@ dispatch("uploading") - const url = `http://${location.hostname}:8188` // TODO make configurable + const url = configState.getBackendURL(); const requests = files.map(async (file) => { const formData = new FormData(); diff --git a/src/lib/components/utils.ts b/src/lib/components/utils.ts index f902626..3a3c8dd 100644 --- a/src/lib/components/utils.ts +++ b/src/lib/components/utils.ts @@ -1,6 +1,6 @@ import type ComfyGraphCanvas from "$lib/ComfyGraphCanvas"; import { type ContainerLayout, type IDragItem, type TemplateLayout, type WritableLayoutStateStore } from "$lib/stores/layoutStates" -import type { LGraphCanvas } from "@litegraph-ts/core"; +import type { LGraphCanvas, Vector2 } from "@litegraph-ts/core"; import { get } from "svelte/store"; export function handleContainerConsider(layoutState: WritableLayoutStateStore, container: ContainerLayout, evt: CustomEvent>): IDragItem[] { @@ -39,12 +39,9 @@ function doInsertTemplate(layoutState: WritableLayoutStateStore, droppedTemplate layoutState.updateChildren(container, newChildren); - const rect = canvas.ds.element.getBoundingClientRect(); - const width = rect?.width || 1; - const height = rect?.height || 1; - const center = canvas.convertOffsetToCanvas([width * 0.5, height * 0.5]); + const newPos: Vector2 = [canvas.visible_area[0] + canvas.visible_area[2] / 2, canvas.visible_area[1] + canvas.visible_area[3] / 2] - canvas.insertTemplate(droppedTemplate.template, center, container, templateItemIndex); + canvas.insertTemplate(droppedTemplate.template, newPos, container, templateItemIndex); return get(layoutState).allItems[container.id].children; } diff --git a/src/lib/nodes/ComfyGraphNode.ts b/src/lib/nodes/ComfyGraphNode.ts index 1577c63..5911b9c 100644 --- a/src/lib/nodes/ComfyGraphNode.ts +++ b/src/lib/nodes/ComfyGraphNode.ts @@ -312,7 +312,13 @@ export default class ComfyGraphNode extends LGraphNode { } (o as any).saveUserState = this.saveUserState - if (!this.saveUserState && (!get(uiState).isSavingToLocalStorage || get(configState).alwaysStripUserState)) { + + let saveUserState = this.saveUserState || get(configState).alwaysStripUserState; + const forceSaveUserState = get(uiState).forceSaveUserState; + if (forceSaveUserState !== null) + saveUserState = forceSaveUserState; + + if (!saveUserState) { this.stripUserState(o) console.debug("[ComfyGraphNode] stripUserState", this, o) } diff --git a/src/lib/stores/configState.ts b/src/lib/stores/configState.ts index ce9e3d5..40f36f8 100644 --- a/src/lib/stores/configState.ts +++ b/src/lib/stores/configState.ts @@ -3,6 +3,12 @@ import { get, writable } from 'svelte/store'; import type { Writable } from 'svelte/store'; export type ConfigState = { + /** Backend domain for ComfyUI */ + comfyUIHostname: string, + + /** Backend port for ComfyUI */ + comfyUIPort: number, + /** Strip user state even if saving to local storage */ alwaysStripUserState: boolean, @@ -14,18 +20,27 @@ export type ConfigState = { } type ConfigStateOps = { + getBackendURL: () => string } export type WritableConfigStateStore = Writable & ConfigStateOps; const store: Writable = writable( { + comfyUIHostname: "localhost", + comfyUIPort: 8188, alwaysStripUserState: false, promptForWorkflowName: false, confirmWhenUnloadingUnsavedChanges: true }) +function getBackendURL(): string { + const state = get(store); + return `${window.location.protocol}://${state.comfyUIHostname}:${state.comfyUIPort}` +} + const configStateStore: WritableConfigStateStore = { - ...store + ...store, + getBackendURL } export default configStateStore; diff --git a/src/lib/stores/layoutStates.ts b/src/lib/stores/layoutStates.ts index 591cf9e..a460bae 100644 --- a/src/lib/stores/layoutStates.ts +++ b/src/lib/stores/layoutStates.ts @@ -1005,6 +1005,9 @@ function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStat store.set(state) } + /* + * NOTE: Modifies the template in-place, be sure you cloned it beforehand! + */ function insertTemplate(template: SerializedComfyBoxTemplate, graph: LGraph, templateNodeIDToNode: Record, container: ContainerLayout, childIndex: number): IDragItem { const idMapping: Record = {}; diff --git a/src/lib/stores/modalState.ts b/src/lib/stores/modalState.ts index 6da90c0..6cb89d8 100644 --- a/src/lib/stores/modalState.ts +++ b/src/lib/stores/modalState.ts @@ -7,7 +7,7 @@ export type ModalState = Record; export type ModalButton = { name: string, variant: "primary" | "secondary", - onClick: (state: ModalData) => void, + onClick: (state: ModalData) => boolean | void, closeOnClick?: boolean } export interface ModalData { diff --git a/src/lib/stores/uiState.ts b/src/lib/stores/uiState.ts index 23ef527..39076ed 100644 --- a/src/lib/stores/uiState.ts +++ b/src/lib/stores/uiState.ts @@ -11,7 +11,7 @@ export type UIState = { uiEditMode: UIEditMode, reconnecting: boolean, - isSavingToLocalStorage: boolean + forceSaveUserState: boolean | null } type UIStateOps = { @@ -29,7 +29,7 @@ const store: Writable = writable( uiEditMode: "widgets", reconnecting: false, - isSavingToLocalStorage: false, + forceSaveUserState: null, }) function reconnecting() { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9da68b4..065e2fb 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,571 +1,600 @@ -import { type WidgetLayout, type WritableLayoutStateStore } from "$lib/stores/layoutStates"; -import selectionState from "$lib/stores/selectionState"; -import type { FileData as GradioFileData } from "@gradio/upload"; -import { Subgraph, type LGraph, type LGraphNode, type LLink, type SerializedLGraph, type UUID, type NodeID, type SlotType } from "@litegraph-ts/core"; -import { get } from "svelte/store"; -import type { ComfyNodeID } from "./api"; -import { type SerializedPrompt } from "./components/ComfyApp"; -import workflowState from "./stores/workflowState"; -import { ImageViewer } from "./ImageViewer"; - -export function clamp(n: number, min: number, max: number): number { - if (max <= min) - return min; - return Math.min(Math.max(n, min), max) -} - -export function negmod(n: number, m: number): number { - return ((n % m) + m) % m; -} - -export function range(size: number, startAt: number = 0): ReadonlyArray { - return [...Array(size).keys()].map(i => i + startAt); -} - -export function countNewLines(str: string): number { - return str.split(/\r\n|\r|\n/).length -} - -export function capitalize(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -export function basename(filepath: string): string { - const filename = filepath.split('/').pop().split('\\').pop(); - return filename.split('.').slice(0, -1).join('.'); -} - -export function truncateString(str: string, num: number): string { - if (num <= 0) - return "…"; - - if (str.length <= num) { - return str; - } - return str.slice(0, num) + "…"; -} - -export function* enumerate(iterable: Iterable): Iterable<[number, T]> { - let index = 0; - for (const value of iterable) { - yield [index++, value]; - } -} - -export async function timeExecutionMs(fn: (...any) => Promise, ...args: any[]): Promise { - const start = new Date().getTime(); - - await fn.apply(null, args) - - return new Date().getTime() - start; -} - -export function download(filename: string, text: string, type: string = "text/plain") { - const blob = new Blob([text], { type: type }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - setTimeout(function() { - a.remove(); - window.URL.revokeObjectURL(url); - }, 0); -} - -export function startDrag(evt: MouseEvent, layoutState: WritableLayoutStateStore) { - const dragItemId: string = evt.target.dataset["dragItemId"]; - const ss = get(selectionState) - const ls = get(layoutState) - - if (evt.button !== 0) { - if (ss.currentSelection.length <= 1 && !ls.isMenuOpen) - ss.currentSelection = [dragItemId] - return; - } - - const item = ls.allItems[dragItemId].dragItem - - console.debug("startDrag", item) - - if (evt.ctrlKey) { - const index = ss.currentSelection.indexOf(item.id) - if (index === -1) - ss.currentSelection.push(item.id); - else - ss.currentSelection.splice(index, 1); - ss.currentSelection = ss.currentSelection; - } - else { - ss.currentSelection = [item.id] - } - ss.currentSelectionNodes = []; - for (const id of ss.currentSelection) { - const item = ls.allItems[id].dragItem - if (item.type === "widget") { - const node = (item as WidgetLayout).node; - if (node) { - ss.currentSelectionNodes.push(node) - } - } - } - - layoutState.set(ls) - selectionState.set(ss) - layoutState.notifyWorkflowModified(); -}; - -export function stopDrag(evt: MouseEvent, layoutState: WritableLayoutStateStore) { - layoutState.notifyWorkflowModified(); -}; - -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 { - links.push(linkText) - } - } - } - } - } - - let out = "digraph {\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 += " color=red;\n"; - // out += " style=grey;\n"; - out += " " + links.join(" ") - out += " }\n" - } - - out += links.join("") - - out += "}" - return out -} - -export function workflowToGraphVis(workflow: SerializedLGraph): string { - let out = "digraph {\n" - - for (const link of workflow.links) { - const nodeA = workflow.nodes.find(n => n.id === link[1]) - const nodeB = workflow.nodes.find(n => n.id === link[3]) - out += `"${link[1]}_${nodeA.title}" -> "${link[3]}_${nodeB.title}"\n`; - } - - out += "}" - return out -} - -export function promptToGraphVis(prompt: SerializedPrompt): string { - let out = "digraph {\n" - - const ids: Record = {} - let nextID = 0; - - for (const pair of Object.entries(prompt.output)) { - const [id, o] = pair; - if (ids[id] == null) - ids[id] = nextID++; - - if ("class_type" in o) { - 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 [inpID, inpSlot] = i; - if (ids[inpID] == null) - ids[inpID] = nextID++; - - const inpNode = prompt.output[inpID] - if (inpNode) { - out += `"${ids[inpID]}_${inpNode.class_type}" -> "${ids[id]}_${o.class_type}"\n` - } - } - else { - const value = String(i).substring(0, 20) - // Value - out += `"${ids[id]}-${inpName}-${value}" -> "${ids[id]}_${o.class_type}"\n` - } - } - } - } - - out += "}" - return out -} - -export function getNodeInfo(nodeId: ComfyNodeID): string { - const workflow = workflowState.getWorkflowByNodeID(nodeId); - if (workflow == null) - return nodeId; - - const title = workflow.graph?.getNodeByIdRecursive(nodeId)?.title; - if (title == null) - return nodeId; - - const displayNodeID = nodeId ? (nodeId.split("-")[0]) : String(nodeId); - return title + " (" + displayNodeID + ")" -} - -export const debounce = (callback: Function, wait = 250) => { - let timeout: NodeJS.Timeout | null = null; - return (...args: Array) => { - const next = () => callback(...args); - if (timeout) clearTimeout(timeout); - - timeout = setTimeout(next, wait); - }; -}; - -export function convertComfyOutputToGradio(output: SerializedPromptOutput): GradioFileData[] { - return output.images.map(convertComfyOutputEntryToGradio); -} - -export function convertComfyOutputEntryToGradio(r: ComfyImageLocation): GradioFileData { - const url = `http://${location.hostname}:8188` // TODO make configurable - const params = new URLSearchParams(r) - const fileData: GradioFileData = { - name: r.filename, - orig_name: r.filename, - is_file: false, - data: url + "/view?" + params - } - return fileData -} - -export function convertComfyOutputToComfyURL(output: string | ComfyImageLocation): string { - if (typeof output === "string") - return output; - - const params = new URLSearchParams(output) - const url = `http://${location.hostname}:8188` // TODO make configurable - return url + "/view?" + params -} - -export function convertGradioFileDataToComfyURL(image: GradioFileData, type: ComfyUploadImageType = "input"): string { - const baseUrl = `http://${location.hostname}:8188` // TODO make configurable - const params = new URLSearchParams({ filename: image.name, subfolder: "", type }) - return `${baseUrl}/view?${params}` -} - -export function convertGradioFileDataToComfyOutput(fileData: GradioFileData, type: ComfyUploadImageType = "input"): ComfyImageLocation { - if (!fileData.is_file) - throw "Can't convert blob data to comfy output!" - - return { - filename: fileData.name, - subfolder: "", - type - } -} - -export function convertFilenameToComfyURL(filename: string, - subfolder: string = "", - type: "input" | "output" | "temp" = "output"): string { - const params = new URLSearchParams({ - filename, - subfolder, - type - }) - const url = `http://${location.hostname}:8188` // TODO make configurable - return url + "/view?" + params -} - -export function jsonToJsObject(json: string): string { - // Try to parse, to see if it's real JSON - JSON.parse(json); - - const regex = /\"([^"]+)\":/g; - const hyphenRegex = /-([a-z])/g; - - return json.replace(regex, match => { - return match - .replace(hyphenRegex, g => g[1].toUpperCase()) - .replace(regex, "$1:"); - }); -} - -export type ComfyUploadImageType = "output" | "input" | "temp" - -export interface ComfyUploadImageAPIResponse { - name: string, // Yes this is different from the "executed" event args - subfolder: string, - type: ComfyUploadImageType -} - -/* - * Uploads an image into ComfyUI's `input` folder. - */ -export async function uploadImageToComfyUI(blob: Blob, filename: string, type: ComfyUploadImageType, subfolder: string = "", overwrite: boolean = false): Promise { - console.debug("[utils] Uploading image to ComfyUI", filename, blob.size) - - const url = `http://${location.hostname}:8188` // TODO make configurable - - const formData = new FormData(); - formData.append("image", blob, filename); - formData.set("type", type) - formData.set("subfolder", subfolder) - formData.set("overwrite", String(overwrite)) - - const req = new Request(url + "/upload/image", { - body: formData, - method: 'POST' - }); - - return fetch(req) - .then((r) => r.json()) - .then((resp) => { - return { - filename: resp.name, - subfolder: resp.subfolder, - type: resp.type - } - }); -} - -/** Raw output as received from ComfyUI's backend */ -export interface SerializedPromptOutput { - // Technically this response can contain arbitrary data, but "images" is the - // most frequently used as it's output by LoadImage and PreviewImage, the - // only two output nodes in base ComfyUI. - images: ComfyImageLocation[] | null, - - /* - * Other data - */ - [key: string]: any -} - -/** Raw output entry as received from ComfyUI's backend */ -export type ComfyImageLocation = { - /* Filename with extension in the subfolder. */ - filename: string, - /* Subfolder in the containing folder. */ - subfolder: string, - /* Base ComfyUI folder where the image is located. */ - type: ComfyUploadImageType -} - -/* - * Convenient type for passing around image filepaths and their metadata with - * wires. Needs to be converted to a filename for use with LoadImage. - * - * Litegraph type is COMFYBOX_IMAGE. The array type is COMFYBOX_IMAGES. - */ -export type ComfyBoxImageMetadata = { - /* For easy structural type detection */ - isComfyBoxImageMetadata: true, - /* Pointer to where this image resides in ComfyUI. */ - comfyUIFile: ComfyImageLocation, - /* Readable name of the image. */ - name: string - /* Tags applicable to this image, like ["mask"]. */ - tags: string[], - /* Image width. */ - width?: number, - /* Image height. */ - height?: number, -} - -export function isComfyBoxImageMetadata(value: any): value is ComfyBoxImageMetadata { - return value && typeof value === "object" && (value as any).isComfyBoxImageMetadata; -} - -export function isComfyBoxImageMetadataArray(value: any): value is ComfyBoxImageMetadata[] { - return Array.isArray(value) && value.every(isComfyBoxImageMetadata); -} - -export function isComfyExecutionResult(value: any): value is SerializedPromptOutput { - return value && typeof value === "object" && Array.isArray(value.images) -} - -export function filenameToComfyBoxMetadata(filename: string, type: ComfyUploadImageType, subfolder: string = ""): ComfyBoxImageMetadata { - return { - isComfyBoxImageMetadata: true, - comfyUIFile: { - filename, - subfolder, - type - }, - name: "Filename", - tags: [], - } -} - -export function comfyFileToComfyBoxMetadata(comfyUIFile: ComfyImageLocation): ComfyBoxImageMetadata { - return { - isComfyBoxImageMetadata: true, - comfyUIFile, - name: "File", - tags: [], - } -} - -/* - * Converts a ComfyUI file into an annotated filepath. Backend nodes like - * LoadImage support syntax like "subfolder/image.png [output]" to specify which - * image folder to load from. - */ -export function comfyFileToAnnotatedFilepath(comfyUIFile: ComfyImageLocation): string { - let path = "" - if (comfyUIFile.subfolder != "") - path = comfyUIFile.subfolder + "/"; - - path += `${comfyUIFile.filename} [${comfyUIFile.type}]` - return path; -} - -export function executionResultToImageMetadata(result: SerializedPromptOutput): ComfyBoxImageMetadata[] { - return result.images.map(comfyFileToComfyBoxMetadata) -} - -export function isComfyImageLocation(param: any): param is ComfyImageLocation { - return param != null && typeof param === "object" - && typeof param.filename === "string" - && typeof param.type === "string" -} - -export function parseWhateverIntoImageMetadata(param: any): ComfyBoxImageMetadata[] | null { - let meta: ComfyBoxImageMetadata[] | null = null - - if (isComfyBoxImageMetadata(param)) { - meta = [param]; - } - else if (Array.isArray(param) && param.every(isComfyBoxImageMetadata)) { - meta = param - } - else if (isComfyExecutionResult(param)) { - meta = executionResultToImageMetadata(param); - } - else if (isComfyImageLocation(param)) { - meta = [comfyFileToComfyBoxMetadata(param)] - } - else if (Array.isArray(param) && param.every(isComfyImageLocation)) { - meta = param.map(comfyFileToComfyBoxMetadata) - } - - return meta; -} - -export function parseWhateverIntoComfyImageLocations(param: any): ComfyImageLocation[] | null { - const meta = parseWhateverIntoImageMetadata(param); - if (!Array.isArray(meta)) - return null - - return meta.map(m => m.comfyUIFile); -} - -export function comfyBoxImageToComfyFile(image: ComfyBoxImageMetadata): ComfyImageLocation { - return image.comfyUIFile -} - -export function comfyBoxImageToComfyURL(image: ComfyBoxImageMetadata): string { - return convertComfyOutputToComfyURL(image.comfyUIFile) -} - -export function comfyURLToComfyFile(urlString: string): ComfyImageLocation | null { - const url = new URL(urlString); - const params = new URLSearchParams(url.search); - const filename = params.get("filename") - const type = params.get("type") as ComfyUploadImageType; - const subfolder = params.get("subfolder") || "" - - // If at least filename and type exist then we're good - if (filename != null && type != null) { - return { filename, type, subfolder } - } - - return null; -} - -export function showLightbox(images: string[], index: number, e: Event) { - e.preventDefault() - if (!images) - return - - ImageViewer.instance.showModal(images, index); - - e.stopPropagation() -} - -export function getLitegraphType(param: any): SlotType | null { - if (param == null) - return null; - - switch (typeof param) { - case "string": - return "string" - case "number": - case "bigint": - return "number" - case "boolean": - return "boolean" - case "object": - if (isComfyBoxImageMetadata(param)) { - return "COMFYBOX_IMAGE" - } - else if (isComfyBoxImageMetadataArray(param)) { - return "COMFYBOX_IMAGES" - } - return null; - case "symbol": - case "undefined": - case "function": - default: - return null; - } -} +import { type WidgetLayout, type WritableLayoutStateStore } from "$lib/stores/layoutStates"; +import selectionState from "$lib/stores/selectionState"; +import type { FileData as GradioFileData } from "@gradio/upload"; +import { Subgraph, type LGraph, type LGraphNode, type LLink, type SerializedLGraph, type UUID, type NodeID, type SlotType, type Vector4, type SerializedLGraphNode } from "@litegraph-ts/core"; +import { get } from "svelte/store"; +import type { ComfyNodeID } from "./api"; +import { type SerializedPrompt } from "./components/ComfyApp"; +import workflowState from "./stores/workflowState"; +import { ImageViewer } from "./ImageViewer"; +import configState from "$lib/stores/configState"; + +export function clamp(n: number, min: number, max: number): number { + if (max <= min) + return min; + return Math.min(Math.max(n, min), max) +} + +export function negmod(n: number, m: number): number { + return ((n % m) + m) % m; +} + +export function range(size: number, startAt: number = 0): ReadonlyArray { + return [...Array(size).keys()].map(i => i + startAt); +} + +export function countNewLines(str: string): number { + return str.split(/\r\n|\r|\n/).length +} + +export function capitalize(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function basename(filepath: string): string { + const filename = filepath.split('/').pop().split('\\').pop(); + return filename.split('.').slice(0, -1).join('.'); +} + +export function truncateString(str: string, num: number): string { + if (num <= 0) + return "…"; + + if (str.length <= num) { + return str; + } + return str.slice(0, num) + "…"; +} + +export function* enumerate(iterable: Iterable): Iterable<[number, T]> { + let index = 0; + for (const value of iterable) { + yield [index++, value]; + } +} + +export async function timeExecutionMs(fn: (...any) => Promise, ...args: any[]): Promise { + const start = new Date().getTime(); + + await fn.apply(null, args) + + return new Date().getTime() - start; +} + +export function download(filename: string, text: string, type: string = "text/plain") { + const blob = new Blob([text], { type: type }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + setTimeout(function() { + a.remove(); + window.URL.revokeObjectURL(url); + }, 0); +} + +export function getLocalStorageUsed(): number { + var total = 0; + for (const x in localStorage) { + // Value is multiplied by 2 due to data being stored in `utf-16` format, which requires twice the space. + const amount = (localStorage[x].length * 2) / 1024 / 1024; + if (!isNaN(amount) && localStorage.hasOwnProperty(x)) { + total += amount; + } + } + return total +}; + +export function startDrag(evt: MouseEvent, layoutState: WritableLayoutStateStore) { + const dragItemId: string = evt.target.dataset["dragItemId"]; + const ss = get(selectionState) + const ls = get(layoutState) + + if (evt.button !== 0) { + if (ss.currentSelection.length <= 1 && !ls.isMenuOpen) + ss.currentSelection = [dragItemId] + return; + } + + const item = ls.allItems[dragItemId].dragItem + + console.debug("startDrag", item) + + if (evt.ctrlKey) { + const index = ss.currentSelection.indexOf(item.id) + if (index === -1) + ss.currentSelection.push(item.id); + else + ss.currentSelection.splice(index, 1); + ss.currentSelection = ss.currentSelection; + } + else { + ss.currentSelection = [item.id] + } + ss.currentSelectionNodes = []; + for (const id of ss.currentSelection) { + const item = ls.allItems[id].dragItem + if (item.type === "widget") { + const node = (item as WidgetLayout).node; + if (node) { + ss.currentSelectionNodes.push(node) + } + } + } + + layoutState.set(ls) + selectionState.set(ss) + layoutState.notifyWorkflowModified(); +}; + +export function stopDrag(evt: MouseEvent, layoutState: WritableLayoutStateStore) { + layoutState.notifyWorkflowModified(); +}; + +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 { + links.push(linkText) + } + } + } + } + } + + let out = "digraph {\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 += " color=red;\n"; + // out += " style=grey;\n"; + out += " " + links.join(" ") + out += " }\n" + } + + out += links.join("") + + out += "}" + return out +} + +export function workflowToGraphVis(workflow: SerializedLGraph): string { + let out = "digraph {\n" + + for (const link of workflow.links) { + const nodeA = workflow.nodes.find(n => n.id === link[1]) + const nodeB = workflow.nodes.find(n => n.id === link[3]) + out += `"${link[1]}_${nodeA.title}" -> "${link[3]}_${nodeB.title}"\n`; + } + + out += "}" + return out +} + +export function promptToGraphVis(prompt: SerializedPrompt): string { + let out = "digraph {\n" + + const ids: Record = {} + let nextID = 0; + + for (const pair of Object.entries(prompt.output)) { + const [id, o] = pair; + if (ids[id] == null) + ids[id] = nextID++; + + if ("class_type" in o) { + 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 [inpID, inpSlot] = i; + if (ids[inpID] == null) + ids[inpID] = nextID++; + + const inpNode = prompt.output[inpID] + if (inpNode) { + out += `"${ids[inpID]}_${inpNode.class_type}" -> "${ids[id]}_${o.class_type}"\n` + } + } + else { + const value = String(i).substring(0, 20) + // Value + out += `"${ids[id]}-${inpName}-${value}" -> "${ids[id]}_${o.class_type}"\n` + } + } + } + } + + out += "}" + return out +} + +export function getNodeInfo(nodeId: ComfyNodeID): string { + const workflow = workflowState.getWorkflowByNodeID(nodeId); + if (workflow == null) + return nodeId; + + const title = workflow.graph?.getNodeByIdRecursive(nodeId)?.title; + if (title == null) + return nodeId; + + const displayNodeID = nodeId ? (nodeId.split("-")[0]) : String(nodeId); + return title + " (" + displayNodeID + ")" +} + +export const debounce = (callback: Function, wait = 250) => { + let timeout: NodeJS.Timeout | null = null; + return (...args: Array) => { + const next = () => callback(...args); + if (timeout) clearTimeout(timeout); + + timeout = setTimeout(next, wait); + }; +}; + +export function convertComfyOutputToGradio(output: SerializedPromptOutput): GradioFileData[] { + return output.images.map(convertComfyOutputEntryToGradio); +} + +export function convertComfyOutputEntryToGradio(r: ComfyImageLocation): GradioFileData { + const url = configState.getBackendURL(); + const params = new URLSearchParams(r) + const fileData: GradioFileData = { + name: r.filename, + orig_name: r.filename, + is_file: false, + data: url + "/view?" + params + } + return fileData +} + +export function convertComfyOutputToComfyURL(output: string | ComfyImageLocation): string { + if (typeof output === "string") + return output; + + const params = new URLSearchParams(output) + const url = configState.getBackendURL(); + return url + "/view?" + params +} + +export function convertGradioFileDataToComfyURL(image: GradioFileData, type: ComfyUploadImageType = "input"): string { + const baseUrl = configState.getBackendURL(); + const params = new URLSearchParams({ filename: image.name, subfolder: "", type }) + return `${baseUrl}/view?${params}` +} + +export function convertGradioFileDataToComfyOutput(fileData: GradioFileData, type: ComfyUploadImageType = "input"): ComfyImageLocation { + if (!fileData.is_file) + throw "Can't convert blob data to comfy output!" + + return { + filename: fileData.name, + subfolder: "", + type + } +} + +export function convertFilenameToComfyURL(filename: string, + subfolder: string = "", + type: "input" | "output" | "temp" = "output"): string { + const params = new URLSearchParams({ + filename, + subfolder, + type + }) + const url = configState.getBackendURL(); + return url + "/view?" + params +} + +export function jsonToJsObject(json: string): string { + // Try to parse, to see if it's real JSON + JSON.parse(json); + + const regex = /\"([^"]+)\":/g; + const hyphenRegex = /-([a-z])/g; + + return json.replace(regex, match => { + return match + .replace(hyphenRegex, g => g[1].toUpperCase()) + .replace(regex, "$1:"); + }); +} + +export type ComfyUploadImageType = "output" | "input" | "temp" + +export interface ComfyUploadImageAPIResponse { + name: string, // Yes this is different from the "executed" event args + subfolder: string, + type: ComfyUploadImageType +} + +/* + * Uploads an image into ComfyUI's `input` folder. + */ +export async function uploadImageToComfyUI(blob: Blob, filename: string, type: ComfyUploadImageType, subfolder: string = "", overwrite: boolean = false): Promise { + console.debug("[utils] Uploading image to ComfyUI", filename, blob.size) + + const url = configState.getBackendURL(); + + const formData = new FormData(); + formData.append("image", blob, filename); + formData.set("type", type) + formData.set("subfolder", subfolder) + formData.set("overwrite", String(overwrite)) + + const req = new Request(url + "/upload/image", { + body: formData, + method: 'POST' + }); + + return fetch(req) + .then((r) => r.json()) + .then((resp) => { + return { + filename: resp.name, + subfolder: resp.subfolder, + type: resp.type + } + }); +} + +/** Raw output as received from ComfyUI's backend */ +export interface SerializedPromptOutput { + // Technically this response can contain arbitrary data, but "images" is the + // most frequently used as it's output by LoadImage and PreviewImage, the + // only two output nodes in base ComfyUI. + images: ComfyImageLocation[] | null, + + /* + * Other data + */ + [key: string]: any +} + +/** Raw output entry as received from ComfyUI's backend */ +export type ComfyImageLocation = { + /* Filename with extension in the subfolder. */ + filename: string, + /* Subfolder in the containing folder. */ + subfolder: string, + /* Base ComfyUI folder where the image is located. */ + type: ComfyUploadImageType +} + +/* + * Convenient type for passing around image filepaths and their metadata with + * wires. Needs to be converted to a filename for use with LoadImage. + * + * Litegraph type is COMFYBOX_IMAGE. The array type is COMFYBOX_IMAGES. + */ +export type ComfyBoxImageMetadata = { + /* For easy structural type detection */ + isComfyBoxImageMetadata: true, + /* Pointer to where this image resides in ComfyUI. */ + comfyUIFile: ComfyImageLocation, + /* Readable name of the image. */ + name: string + /* Tags applicable to this image, like ["mask"]. */ + tags: string[], + /* Image width. */ + width?: number, + /* Image height. */ + height?: number, +} + +export function isComfyBoxImageMetadata(value: any): value is ComfyBoxImageMetadata { + return value && typeof value === "object" && (value as any).isComfyBoxImageMetadata; +} + +export function isComfyBoxImageMetadataArray(value: any): value is ComfyBoxImageMetadata[] { + return Array.isArray(value) && value.every(isComfyBoxImageMetadata); +} + +export function isComfyExecutionResult(value: any): value is SerializedPromptOutput { + return value && typeof value === "object" && Array.isArray(value.images) +} + +export function filenameToComfyBoxMetadata(filename: string, type: ComfyUploadImageType, subfolder: string = ""): ComfyBoxImageMetadata { + return { + isComfyBoxImageMetadata: true, + comfyUIFile: { + filename, + subfolder, + type + }, + name: "Filename", + tags: [], + } +} + +export function comfyFileToComfyBoxMetadata(comfyUIFile: ComfyImageLocation): ComfyBoxImageMetadata { + return { + isComfyBoxImageMetadata: true, + comfyUIFile, + name: "File", + tags: [], + } +} + +/* + * Converts a ComfyUI file into an annotated filepath. Backend nodes like + * LoadImage support syntax like "subfolder/image.png [output]" to specify which + * image folder to load from. + */ +export function comfyFileToAnnotatedFilepath(comfyUIFile: ComfyImageLocation): string { + let path = "" + if (comfyUIFile.subfolder != "") + path = comfyUIFile.subfolder + "/"; + + path += `${comfyUIFile.filename} [${comfyUIFile.type}]` + return path; +} + +export function executionResultToImageMetadata(result: SerializedPromptOutput): ComfyBoxImageMetadata[] { + return result.images.map(comfyFileToComfyBoxMetadata) +} + +export function isComfyImageLocation(param: any): param is ComfyImageLocation { + return param != null && typeof param === "object" + && typeof param.filename === "string" + && typeof param.type === "string" +} + +export function parseWhateverIntoImageMetadata(param: any): ComfyBoxImageMetadata[] | null { + let meta: ComfyBoxImageMetadata[] | null = null + + if (isComfyBoxImageMetadata(param)) { + meta = [param]; + } + else if (Array.isArray(param) && param.every(isComfyBoxImageMetadata)) { + meta = param + } + else if (isComfyExecutionResult(param)) { + meta = executionResultToImageMetadata(param); + } + else if (isComfyImageLocation(param)) { + meta = [comfyFileToComfyBoxMetadata(param)] + } + else if (Array.isArray(param) && param.every(isComfyImageLocation)) { + meta = param.map(comfyFileToComfyBoxMetadata) + } + + return meta; +} + +export function parseWhateverIntoComfyImageLocations(param: any): ComfyImageLocation[] | null { + const meta = parseWhateverIntoImageMetadata(param); + if (!Array.isArray(meta)) + return null + + return meta.map(m => m.comfyUIFile); +} + +export function comfyBoxImageToComfyFile(image: ComfyBoxImageMetadata): ComfyImageLocation { + return image.comfyUIFile +} + +export function comfyBoxImageToComfyURL(image: ComfyBoxImageMetadata): string { + return convertComfyOutputToComfyURL(image.comfyUIFile) +} + +export function comfyURLToComfyFile(urlString: string): ComfyImageLocation | null { + const url = new URL(urlString); + const params = new URLSearchParams(url.search); + const filename = params.get("filename") + const type = params.get("type") as ComfyUploadImageType; + const subfolder = params.get("subfolder") || "" + + // If at least filename and type exist then we're good + if (filename != null && type != null) { + return { filename, type, subfolder } + } + + return null; +} + +export function showLightbox(images: string[], index: number, e: Event) { + e.preventDefault() + if (!images) + return + + ImageViewer.instance.showModal(images, index); + + e.stopPropagation() +} + +export function getLitegraphType(param: any): SlotType | null { + if (param == null) + return null; + + switch (typeof param) { + case "string": + return "string" + case "number": + case "bigint": + return "number" + case "boolean": + return "boolean" + case "object": + if (isComfyBoxImageMetadata(param)) { + return "COMFYBOX_IMAGE" + } + else if (isComfyBoxImageMetadataArray(param)) { + return "COMFYBOX_IMAGES" + } + return null; + case "symbol": + case "undefined": + case "function": + default: + return null; + } +} + +export function calcNodesBoundingBox(nodes: SerializedLGraphNode[]): Vector4 { + let min_x = Number.MAX_SAFE_INTEGER; + let max_x = 0; + let min_y = Number.MAX_SAFE_INTEGER; + let max_y = 0; + + for (const node of Object.values(nodes)) { + min_x = Math.min(node.pos[0], min_x); + max_x = Math.max(node.pos[0] + node.size[0], max_x); + min_y = Math.min(node.pos[1], min_y); + max_y = Math.max(node.pos[1] + node.size[1], max_y); + } + + return [min_x, min_y, max_x, max_y]; +} diff --git a/src/lib/widgets/ImageUploadWidget.svelte b/src/lib/widgets/ImageUploadWidget.svelte index f381361..09f6fa6 100644 --- a/src/lib/widgets/ImageUploadWidget.svelte +++ b/src/lib/widgets/ImageUploadWidget.svelte @@ -11,6 +11,7 @@ import "klecks/style/style.scss"; import ImageUpload from "$lib/components/ImageUpload.svelte"; import { uploadImageToComfyUI, type ComfyBoxImageMetadata, comfyFileToComfyBoxMetadata, comfyBoxImageToComfyURL, comfyBoxImageToComfyFile, type ComfyUploadImageType, type ComfyImageLocation } from "$lib/utils"; + import configState from "$lib/stores/configState"; import notify from "$lib/notify"; import NumberInput from "$lib/components/NumberInput.svelte"; import type { ComfyImageEditorNode } from "$lib/nodes/widgets"; @@ -102,7 +103,7 @@ showModal = true; - const url = `http://${location.hostname}:8188` // TODO make configurable + const url = configState.getBackendURL(); kl = new Klecks({ embedUrl: url, diff --git a/src/lib/widgets/TextWidgetCodeVariant.svelte b/src/lib/widgets/TextWidgetCodeVariant.svelte index 4990c19..a7db99f 100644 --- a/src/lib/widgets/TextWidgetCodeVariant.svelte +++ b/src/lib/widgets/TextWidgetCodeVariant.svelte @@ -12,6 +12,7 @@ import { basicSetup } from "./TextWidgetCodeVariant"; import { createEventDispatcher, onMount } from "svelte"; import { TAG_CATEGORY_COLORS } from "$lib/DanbooruTags"; + import { Block, BlockTitle } from "@gradio/atoms"; export let widget: WidgetLayout; export let node: ComfyTextNode; @@ -176,7 +177,7 @@ function getExtensions(): Extension[] { // TODO const readonly = false; - const placeholder = "Placeholder..." + const placeholder = "" const dark_mode = true; const stateExtensions: Extension[] = [ @@ -206,12 +207,22 @@ } -
-
+
+ + {widget.attrs.title} +
+
+
+
-