diff --git a/src/lib/components/ComfyApp.svelte b/src/lib/components/ComfyApp.svelte index 28e77bb..bab2f5c 100644 --- a/src/lib/components/ComfyApp.svelte +++ b/src/lib/components/ComfyApp.svelte @@ -7,11 +7,9 @@ import LightboxModal from "./LightboxModal.svelte"; import Sidebar from "./Sidebar.svelte"; import SidebarItem from "./SidebarItem.svelte"; - // import Modal from "./Modal.svelte"; - // import A1111PromptDisplay from "./A1111PromptDisplay.svelte"; - import notify from "$lib/notify"; - import ComfyWorkflowsView from "./ComfyWorkflowsView.svelte"; - + import notify from "$lib/notify"; + import ComfyWorkflowsView from "./ComfyWorkflowsView.svelte"; + import GlobalModal from "./GlobalModal.svelte"; export let app: ComfyApp = undefined; let hasShownUIHelpToast: boolean = false; @@ -36,11 +34,6 @@ document.getElementById("app-root").classList.remove("dark") } - // let showModal: boolean = false; - // - // $: showModal = $a1111Prompt != null - // - // let selectedTab @@ -49,20 +42,6 @@ {/if} - -
@@ -74,6 +53,7 @@
+
diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index 46606cf..8da6407 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -1,9 +1,11 @@ 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, type UUID } 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, parsePNGMetadata } from "$lib/pnginfo"; +import { importA1111, parsePNGMetadata } from "$lib/pnginfo"; import EventEmitter from "events"; import type TypedEmitter from "typed-emitter"; +import A1111PromptDisplay from "./A1111PromptDisplay.svelte"; + // Import nodes import "@litegraph-ts/nodes-basic" @@ -28,7 +30,7 @@ import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; import { get, writable, type Writable } from "svelte/store"; import { tick } from "svelte"; import uiState from "$lib/stores/uiState"; -import { download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis } from "$lib/utils"; +import { basename, 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"; @@ -45,7 +47,7 @@ import layoutStates from "$lib/stores/layoutStates"; import { ComfyWorkflow, type WorkflowAttributes, type WorkflowInstID } from "$lib/stores/workflowState"; import workflowState from "$lib/stores/workflowState"; import convertVanillaWorkflow, { type ComfyVanillaWorkflow } from "$lib/convertVanillaWorkflow"; -import convertVanillaWorkflow from "$lib/convertVanillaWorkflow"; +import modalState from "$lib/stores/modalState"; export const COMFYBOX_SERIAL_VERSION = 1; @@ -142,6 +144,11 @@ type CanvasState = { canvas: ComfyGraphCanvas, } +export type WorkflowLoadError = { + message: string, + error: Error +} + function isComfyBoxWorkflow(data: any): data is SerializedAppState { return data != null && (typeof data === "object") && data.comfyBoxWorkflow; } @@ -167,7 +174,6 @@ export default class ComfyApp { ctrlDown: boolean = false; selectedGroupMoving: boolean = false; alreadySetup: Writable = writable(false); - a1111Prompt: Writable = writable(null); private queueItems: PromptQueueItem[] = []; private processingQueue: boolean = false; @@ -266,10 +272,10 @@ export default class ComfyApp { } - convertVanillaWorkflow(workflow: ComfyVanillaWorkflow): SerializedAppState { + convertVanillaWorkflow(workflow: ComfyVanillaWorkflow, title: string): SerializedAppState { const attrs: WorkflowAttributes = { ...defaultWorkflowAttributes, - title: "ComfyUI Workflow" + title } const canvas: SerializedGraphCanvasState = { @@ -579,8 +585,8 @@ export default class ComfyApp { return workflow; } - async openVanillaWorkflow(data: SerializedLGraph) { - const converted = this.convertVanillaWorkflow(data) + async openVanillaWorkflow(data: SerializedLGraph, filename: string) { + const converted = this.convertVanillaWorkflow(data, basename(filename)) console.info("WORKFLWO", converted) notify("Converted ComfyUI workflow to ComfyBox format.", { type: "info" }) await this.openWorkflow(converted); @@ -818,7 +824,7 @@ export default class ComfyApp { await this.openWorkflow(JSON.parse(pngInfo.comfyBoxWorkflow)); } else if (pngInfo.workflow) { const workflow = JSON.parse(pngInfo.workflow); - await this.openVanillaWorkflow(workflow); + await this.openVanillaWorkflow(workflow, file.name); } else if (pngInfo.parameters) { const parsed = parseA1111(pngInfo.parameters) if ("error" in parsed) { @@ -826,11 +832,19 @@ export default class ComfyApp { return; } const converted = convertA1111ToStdPrompt(parsed) - this.a1111Prompt.set({ + const a1111Info: A1111PromptAndInfo = { infotext: pngInfo.parameters, parsedInfotext: parsed, stdPrompt: converted, imageFile: file + } + modalState.pushModal({ + title: "A1111 Prompt Details", + svelteComponent: A1111PromptDisplay, + svelteProps: { + prompt: a1111Info + }, + showCloseButton: true }) } else { @@ -846,7 +860,7 @@ export default class ComfyApp { await this.openWorkflow(result); } else if (isVanillaWorkflow(result)) { - await this.openVanillaWorkflow(result); + await this.openVanillaWorkflow(result, file.name); } }; reader.readAsText(file); @@ -990,6 +1004,5 @@ export default class ComfyApp { * Clean current state */ clean() { - this.a1111Prompt.set(null); } } diff --git a/src/lib/components/ComfyGraphView.svelte b/src/lib/components/ComfyGraphView.svelte index 76ba6d5..a71e497 100644 --- a/src/lib/components/ComfyGraphView.svelte +++ b/src/lib/components/ComfyGraphView.svelte @@ -1,6 +1,7 @@ + +{#each $modalState.activeModals as modal(modal.id)} + onClose(modal)}> + + + {#if modal != null && modal.svelteComponent != null} + + {/if} + +
+ {#if modal != null && modal.buttons?.length > 0} + {#each modal.buttons as button} + + {/each} + {/if} + {#if modal.showCloseButton} + + {/if} +
+
+{/each} diff --git a/src/lib/components/PromptDisplay.svelte b/src/lib/components/PromptDisplay.svelte index 7504d1a..9f86644 100644 --- a/src/lib/components/PromptDisplay.svelte +++ b/src/lib/components/PromptDisplay.svelte @@ -8,6 +8,7 @@ import Gallery from "$lib/components/gradio/gallery/Gallery.svelte"; import { ImageViewer } from "$lib/ImageViewer"; import type { Styles } from "@gradio/utils"; + import { countNewLines } from "$lib/utils"; const splitLength = 50; @@ -29,10 +30,6 @@ && typeof input[1] === "number" } - function countNewLines(str: string): number { - return str.split(/\r\n|\r|\n/).length - } - function isMultiline(input: any): boolean { return typeof input === "string" && (input.length > splitLength || countNewLines(input) > 1); } diff --git a/src/lib/convertVanillaWorkflow.ts b/src/lib/convertVanillaWorkflow.ts index 31c3e1b..50933f0 100644 --- a/src/lib/convertVanillaWorkflow.ts +++ b/src/lib/convertVanillaWorkflow.ts @@ -1,6 +1,6 @@ import { LGraph, type INodeInputSlot, type SerializedLGraph, type LinkID, type UUID, type NodeID, LiteGraph, BuiltInSlotType, type SerializedLGraphNode, type Vector2, BuiltInSlotShape, type INodeOutputSlot } from "@litegraph-ts/core"; import type { SerializedAppState } from "./components/ComfyApp"; -import layoutStates, { defaultWorkflowAttributes, type DragItemID, type SerializedDragEntry, type SerializedLayoutState } from "./stores/layoutStates"; +import layoutStates, { defaultWorkflowAttributes, type ContainerLayout, type DragItemID, type SerializedDragEntry, type SerializedLayoutState } from "./stores/layoutStates"; import { ComfyWorkflow, type WorkflowAttributes } from "./stores/workflowState"; import type { SerializedGraphCanvasState } from "./ComfyGraphCanvas"; import ComfyApp from "./components/ComfyApp"; @@ -12,6 +12,7 @@ import type { SerializedComfyWidgetNode } from "./nodes/widgets/ComfyWidgetNode" import { v4 as uuidv4 } from "uuid" import type ComfyWidgetNode from "./nodes/widgets/ComfyWidgetNode"; import { ComfyGalleryNode } from "./nodes/widgets"; +import { countNewLines } from "./utils"; /* * The workflow type used by base ComfyUI @@ -40,7 +41,6 @@ const vanillaToComfyBoxNodeMapping: Record = { /* * Version of LGraphNode.getConnectionPos but for serialized nodes. - * * TODO handle other node types! (horizontal, hardcoded slot pos, collapsed...) */ function getConnectionPos(node: SerializedLGraphNode, is_input: boolean, slotNumber: number, out: Vector2 = [0, 0]): Vector2 { @@ -63,7 +63,7 @@ function createSerializedWidgetNode(vanillaWorkflow: ComfyVanillaWorkflow, node: comfyWidgetNode.flags.collapsed = true; const size: Vector2 = [0, 0]; - // Compute collapsed size, sinze computeSize() ignores the collapsed flag + // Compute collapsed size, since computeSize() ignores the collapsed flag // LiteGraph only computes it if the node is rendered const fontSize = LiteGraph.NODE_TEXT_SIZE; size[0] = Math.min( @@ -81,6 +81,14 @@ function createSerializedWidgetNode(vanillaWorkflow: ComfyVanillaWorkflow, node: else serWidgetNode.pos[0] += 20; serWidgetNode.pos[1] += LiteGraph.NODE_TITLE_HEIGHT / 2; + + if (widgetNodeType === "ui/text" && typeof value === "string" && value.indexOf("\n") != -1) { + const lineCount = countNewLines(value); + serWidgetNode.properties.multiline = true; + serWidgetNode.properties.lines = lineCount + 2 + serWidgetNode.properties.maxLines = lineCount + 2 + } + vanillaWorkflow.nodes.push(serWidgetNode) return [comfyWidgetNode, serWidgetNode]; @@ -97,6 +105,59 @@ function connectSerializedNodes(vanillaWorkflow: ComfyVanillaWorkflow, originNod vanillaWorkflow.links.push([newLinkID, originNode.id, originSlot, targetNode.id, targetSlot, connInput.type]) } +/* + * Converts all the IDs in the serialized graph into UUID format + */ +function rewriteIDsInGraph(vanillaWorkflow: ComfyVanillaWorkflow) { + const nodeIDs: Record = {}; + const linkIDs: Record = {}; + + const getNodeID = (id: NodeID): UUID => { + if (typeof id === "string") + return id + nodeIDs[id] ||= uuidv4(); + return nodeIDs[id]; + } + + const getLinkID = (id: LinkID): UUID => { + if (typeof id === "string") + return id + linkIDs[id] ||= uuidv4(); + return linkIDs[id]; + } + + for (const node of vanillaWorkflow.nodes) { + node.id = getNodeID(node.id); + if (node.inputs != null) { + for (const input of node.inputs) { + if (input.link != null) { + input.link = getLinkID(input.link) + } + } + } + if (node.outputs != null) { + for (const output of node.outputs) { + if (output.links != null) + output.links = output.links.map(getLinkID); + } + } + } + + for (const link of vanillaWorkflow.links) { + link[0] = getLinkID(link[0]) + link[1] = getNodeID(link[1]) + link[3] = getNodeID(link[3]) + } + + + // Recurse! + for (const node of vanillaWorkflow.nodes) { + if (node.type === "graph/subgraph") { + rewriteIDsInGraph((node as any).subgraph as SerializedLGraph) + } + } +} + export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWorkflow, attrs: WorkflowAttributes): ComfyWorkflow { const [comfyBoxWorkflow, layoutState] = ComfyWorkflow.create(); const { root, left, right } = layoutState.initDefaultLayout(); @@ -104,6 +165,8 @@ export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWork // TODO will need to convert IDs to UUIDs const idToUUID: Record = {} + rewriteIDsInGraph(vanillaWorkflow); + for (const [id, node] of Object.entries(vanillaWorkflow.nodes)) { const newType = vanillaToComfyBoxNodeMapping[node.type]; if (newType != null) { @@ -201,10 +264,22 @@ export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWork widgetNodeType, value); + switch (widgetNodeType) { + case "ui/number": + serWidgetNode.properties.min = inputOpts?.min || 0; + serWidgetNode.properties.max = inputOpts?.max || 100; + serWidgetNode.properties.step = inputOpts?.step || 1; + break; + case "ui/text": + serWidgetNode.properties.multiline = inputOpts?.multiline || false; + break; + } + if (group == null) group = layoutState.addContainer(isOutputNode ? right : left, { title: node.title || node.type }) - layoutState.addWidget(group, comfyWidgetNode) + const widget = layoutState.addWidget(group, comfyWidgetNode) + widget.attrs.title = inputName; const connOutputIndex = serWidgetNode.outputs?.findIndex(o => o.name === comfyWidgetNode.outputSlotName) if (connOutputIndex != null) { @@ -244,7 +319,8 @@ export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWork if (group == null) group = layoutState.addContainer(isOutputNode ? right : left, { title: node.title || node.type }) - layoutState.addWidget(group, comfyGalleryNode) + const widget = layoutState.addWidget(group, comfyGalleryNode) + widget.attrs.title = "Output" const connInputIndex = serGalleryNode.inputs?.findIndex(o => o.name === comfyGalleryNode.storeActionName) if (connInputIndex != null) { diff --git a/src/lib/nodes/widgets/ComfyTextNode.ts b/src/lib/nodes/widgets/ComfyTextNode.ts index 4a8d35e..baab9a2 100644 --- a/src/lib/nodes/widgets/ComfyTextNode.ts +++ b/src/lib/nodes/widgets/ComfyTextNode.ts @@ -5,13 +5,17 @@ import ComfyWidgetNode, { type ComfyWidgetProperties } from "./ComfyWidgetNode"; export interface ComfyTextProperties extends ComfyWidgetProperties { multiline: boolean; + lines: number; + maxLines: number; } export default class ComfyTextNode extends ComfyWidgetNode { override properties: ComfyTextProperties = { tags: [], defaultValue: "", - multiline: false + multiline: false, + lines: 5, + maxLines: 5, } static slotLayout: SlotLayout = { diff --git a/src/lib/stores/layoutStates.ts b/src/lib/stores/layoutStates.ts index 4b022dd..652edcb 100644 --- a/src/lib/stores/layoutStates.ts +++ b/src/lib/stores/layoutStates.ts @@ -428,6 +428,38 @@ const ALL_ATTRIBUTES: AttributesSpecList = [ defaultValue: "gallery", refreshPanelOnChange: true }, + + // Text + { + name: "multiline", + type: "boolean", + location: "nodeProps", + editable: true, + validNodeTypes: ["ui/text"], + defaultValue: false + }, + { + name: "lines", + type: "number", + location: "nodeProps", + editable: true, + validNodeTypes: ["ui/text"], + defaultValue: 5, + min: 1, + max: 100, + step: 1 + }, + { + name: "maxLines", + type: "number", + location: "nodeProps", + editable: true, + validNodeTypes: ["ui/text"], + defaultValue: 5, + min: 1, + max: 100, + step: 1 + }, ] }, { diff --git a/src/lib/stores/modalState.ts b/src/lib/stores/modalState.ts new file mode 100644 index 0000000..cf4ab6c --- /dev/null +++ b/src/lib/stores/modalState.ts @@ -0,0 +1,66 @@ +import type { SvelteComponentDev } from "svelte/internal"; +import { writable, type Writable } from "svelte/store"; +import { v4 as uuidv4 } from "uuid"; + +export type ModalButton = { + name: string, + variant: "primary" | "secondary", + onClick: () => void +} +export interface ModalData { + id: string, + title: string, + onClose?: () => void, + svelteComponent?: typeof SvelteComponentDev, + svelteProps?: Record, + buttons?: ModalButton[], + showCloseButton?: boolean +} +export interface ModalState { + activeModals: ModalData[] +} + +export interface ModalStateOps { + pushModal: (data: Partial) => void, + closeModal: (id: string) => void, + closeAllModals: () => void, +} + +export type WritableModalStateStore = Writable & ModalStateOps; +const store: Writable = writable( + { + activeModals: [] + }) + +function pushModal(data: Partial) { + const modal: ModalData = { + title: "Modal", + ...data, + id: uuidv4(), + } + + store.update(s => { + s.activeModals.push(modal); + return s; + }) +} + +function closeModal(id: string) { + store.update(s => { + s.activeModals = s.activeModals.filter(m => m.id !== id) + return s; + }) +} + +function closeAllModals() { + store.set({ activeModals: [] }) +} + +const modalStateStore: WritableModalStateStore = +{ + ...store, + pushModal, + closeModal, + closeAllModals +} +export default modalStateStore; diff --git a/src/lib/stores/workflowState.ts b/src/lib/stores/workflowState.ts index 9efca6f..67ac863 100644 --- a/src/lib/stores/workflowState.ts +++ b/src/lib/stores/workflowState.ts @@ -1,5 +1,5 @@ import type { SerializedGraphCanvasState } from '$lib/ComfyGraphCanvas'; -import { clamp, LGraphNode, type LGraphCanvas, type NodeID, type SerializedLGraph, type UUID, LGraph } from '@litegraph-ts/core'; +import { clamp, LGraphNode, type LGraphCanvas, type NodeID, type SerializedLGraph, type UUID, LGraph, LiteGraph } from '@litegraph-ts/core'; import { get, writable } from 'svelte/store'; import type { Readable, Writable } from 'svelte/store'; import { defaultWorkflowAttributes, type SerializedLayoutState, type WritableLayoutStateStore } from './layoutStates'; @@ -76,6 +76,11 @@ export class ComfyWorkflow { */ isModified: boolean = false; + /* + * Missing node types encountered when deserializing the graph + */ + missingNodeTypes: string[]; + get layout(): WritableLayoutStateStore | null { return layoutStates.getLayout(this.id) } @@ -170,6 +175,18 @@ export class ComfyWorkflow { } deserialize(layoutState: WritableLayoutStateStore, data: SerializedWorkflowState) { + this.missingNodeTypes = [] + + for (let n of data.graph.nodes) { + // Patch T2IAdapterLoader to ControlNetLoader since they are the same node now + if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader"; + + // Find missing node types + if (!(n.type in LiteGraph.registered_node_types)) { + this.missingNodeTypes.push(n.type); + } + } + // Ensure loadGraphData does not trigger any state changes in layoutState // (isConfiguring is set to true here) // lGraph.configure will add new nodes, triggering onNodeAdded, but we diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 35c2162..f28c646 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -19,6 +19,25 @@ 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 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) { diff --git a/src/lib/widgets/TextWidget.svelte b/src/lib/widgets/TextWidget.svelte index 2106278..edad00b 100644 --- a/src/lib/widgets/TextWidget.svelte +++ b/src/lib/widgets/TextWidget.svelte @@ -35,8 +35,8 @@ bind:value={$nodeValue} label={widget.attrs.title} disabled={isDisabled(widget)} - lines={node.properties.multiline ? 5 : 1} - max_lines={node.properties.multiline ? 5 : 1} + lines={node.properties.multiline ? node.properties.lines : 1} + max_lines={node.properties.multiline ? node.properties.maxLines : 1} show_label={widget.attrs.title !== ""} on:change on:submit