Merge pull request #55 from space-nuko/multiple-workflows

Multiple workflows
This commit is contained in:
space-nuko
2023-05-20 23:48:16 -05:00
committed by GitHub
50 changed files with 2633 additions and 1379 deletions

View File

@@ -18,6 +18,8 @@ This project is *still under construction* and some features are missing, be awa
This frontend isn't compatible with regular ComfyUI's workflow format since extra metadata is saved like panel layout, so you'll have to spend a bit of time recreating them. This project also isn't compatible with regular ComfyUI's frontend extension format, but useful extensions can be integrated into the base repo with some effort.
Also note that the saved workflow format is subject to change until it's been finalized after enough testing, so be prepared to lose some of your work from time to time.
## Features
- *No-Code UI Builder* - A novel system for creating your own Stable Diffusion user interfaces from the basic components.
- *Extension Support* - All custom ComfyUI nodes are supported out of the box.

View File

@@ -1,6 +1,11 @@
{
"createdBy": "ComfyBox",
"version": 1,
"attrs": {
"title": "Default",
"queuePromptButtonName": "Queue txt2img",
"queuePromptButtonRunWorkflow": false
},
"workflow": {
"last_node_id": 0,
"last_link_id": 0,
@@ -25709,10 +25714,6 @@
],
"parent": "eae32e42-1ccc-4a4a-923f-7ab4ccdac97a"
}
},
"attrs": {
"queuePromptButtonName": "Queue txt2img",
"queuePromptButtonRunWorkflow": false
}
},
"canvas": {

View File

@@ -2,7 +2,6 @@ import { LConnectionKind, LGraph, LGraphNode, type INodeSlot, type SlotIndex, Li
import GraphSync from "./GraphSync";
import EventEmitter from "events";
import type TypedEmitter from "typed-emitter";
import layoutState from "./stores/layoutState";
import uiState from "./stores/uiState";
import { get } from "svelte/store";
import type ComfyGraphNode from "./nodes/ComfyGraphNode";
@@ -10,6 +9,11 @@ import type IComfyInputSlot from "./IComfyInputSlot";
import type { ComfyBackendNode } from "./nodes/ComfyBackendNode";
import type { ComfyComboNode, ComfyWidgetNode } from "./nodes/widgets";
import selectionState from "./stores/selectionState";
import type { WritableLayoutStateStore } from "./stores/layoutStates";
import type { WorkflowInstID } from "./components/ComfyApp";
import layoutStates from "./stores/layoutStates";
import type { ComfyWorkflow } from "./stores/workflowState";
import workflowState from "./stores/workflowState";
type ComfyGraphEvents = {
configured: (graph: LGraph) => void
@@ -25,11 +29,28 @@ type ComfyGraphEvents = {
export default class ComfyGraph extends LGraph {
eventBus: TypedEmitter<ComfyGraphEvents> = new EventEmitter() as TypedEmitter<ComfyGraphEvents>;
workflowID: WorkflowInstID | null = null;
get workflow(): ComfyWorkflow | null {
const workflowID = (this.getRootGraph() as ComfyGraph)?.workflowID;
if (workflowID == null)
return null;
return workflowState.getWorkflow(workflowID)
}
constructor(workflowID?: WorkflowInstID) {
super();
this.workflowID = workflowID;
}
override onConfigure() {
console.debug("Configured");
}
override onBeforeChange(graph: LGraph, info: any) {
if (this.workflow != null)
this.workflow.notifyModified()
console.debug("BeforeChange", info);
}
@@ -50,25 +71,33 @@ export default class ComfyGraph extends LGraph {
override onNodeAdded(node: LGraphNode, options: LGraphAddNodeOptions) {
// Don't add nodes in subgraphs until this callback reaches the root
// graph
if (node.getRootGraph() == null || this._is_subgraph)
return;
// Only root graphs will have a workflow ID, so we don't mind subgraphs
// missing it
if (node.getRootGraph() != null && !this._is_subgraph && this.workflowID != null) {
const layoutState = get(layoutStates).all[this.workflowID]
if (layoutState === null) {
throw new Error(`LGraph with workflow missing layout! ${this.workflowID}`)
}
this.doAddNode(node, options);
this.doAddNode(node, layoutState, options);
}
if (this.workflow != null)
this.workflow.notifyModified()
// console.debug("Added", node);
this.eventBus.emit("nodeAdded", node);
}
/*
* Add widget UI/groups for newly added nodes.
*/
private doAddNode(node: LGraphNode, options: LGraphAddNodeOptions) {
private doAddNode(node: LGraphNode, layoutState: WritableLayoutStateStore, options: LGraphAddNodeOptions) {
layoutState.nodeAdded(node, options)
// All nodes whether they come from base litegraph or ComfyBox should
// have tags added to them. Can't override serialization for existing
// node types to add `tags` as a new field so putting it in properties
// is better.
// have tags added to them. Can't override serialization for litegraph's
// base node types to add `tags` as a new field so putting it in
// properties is better.
if (node.properties.tags == null)
node.properties.tags = []
@@ -104,7 +133,6 @@ export default class ComfyGraph extends LGraph {
}
if (get(uiState).autoAddUI) {
console.warn("ADD", node.type, options)
if (!("svelteComponentType" in node) && options.addedBy == null) {
console.debug("[ComfyGraph] AutoAdd UI")
const comfyNode = node as ComfyGraphNode;
@@ -144,14 +172,24 @@ export default class ComfyGraph extends LGraph {
// ************** RECURSION ALERT ! **************
if (node.is(Subgraph)) {
for (const child of node.subgraph.iterateNodesInOrder()) {
this.doAddNode(child, options)
this.doAddNode(child, layoutState, options)
}
}
// ************** RECURSION ALERT ! **************
if (this.workflow != null)
this.workflow.notifyModified()
}
override onNodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) {
selectionState.clear(); // safest option
if (!this._is_subgraph && this.workflowID != null) {
const layoutState = get(layoutStates).all[this.workflowID]
if (layoutState === null) {
throw new Error(`ComfyGraph with workflow missing layout! ${this.workflowID}`)
}
layoutState.nodeRemoved(node, options);
// Handle subgraphs being removed
@@ -160,12 +198,23 @@ export default class ComfyGraph extends LGraph {
this.onNodeRemoved(child, options)
}
}
}
if (this.workflow != null)
this.workflow.notifyModified()
// console.debug("Removed", node);
this.eventBus.emit("nodeRemoved", node);
}
override onInputsOutputsChange() {
if (this.workflow != null)
this.workflow.notifyModified()
}
override onNodeConnectionChange(kind: LConnectionKind, node: LGraphNode, slot: SlotIndex, targetNode: LGraphNode, targetSlot: SlotIndex) {
if (this.workflow != null)
this.workflow.notifyModified()
// console.debug("ConnectionChange", node);
this.eventBus.emit("nodeConnectionChanged", kind, node, slot, targetNode, targetSlot);
}

View File

@@ -3,11 +3,12 @@ import type ComfyApp from "./components/ComfyApp";
import queueState from "./stores/queueState";
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";
import type ComfyGraph from "./ComfyGraph";
import layoutStates from "./stores/layoutStates";
export type SerializedGraphCanvasState = {
offset: Vector2,
@@ -18,9 +19,14 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
app: ComfyApp | null;
private _unsubscribe: Unsubscriber;
get comfyGraph(): ComfyGraph | null {
return this.graph as ComfyGraph;
}
constructor(
app: ComfyApp,
canvas: HTMLCanvasElement | string,
graph?: ComfyGraph,
options: {
skip_render?: boolean;
skip_events?: boolean;
@@ -28,7 +34,7 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
viewport?: Vector4;
} = {}
) {
super(canvas, app.lGraph, options);
super(canvas, graph, options);
this.app = app;
this._unsubscribe = selectionState.subscribe(ss => {
for (const node of Object.values(this.selected_nodes)) {
@@ -281,12 +287,15 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
selectionState.update(ss => {
ss.currentSelectionNodes = Object.values(nodes)
ss.currentSelection = []
const layoutState = layoutStates.getLayoutByGraph(this.graph);
if (layoutState) {
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
})
}
@@ -298,12 +307,15 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
ss.currentHoveredNodes.add(node.id)
}
ss.currentHovered.clear()
const layoutState = layoutStates.getLayoutByGraph(this.graph);
if (layoutState) {
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
})
}

View File

@@ -27,7 +27,18 @@ export type ComfyNodeDefInput = [ComfyNodeDefInputType, ComfyNodeDefInputOptions
export type ComfyNodeDefInputType = any[] | keyof typeof ComfyWidgets | string
export type ComfyNodeDefInputOptions = {
forceInput?: boolean
forceInput?: boolean;
// NOTE: For COMBO type inputs, the default value is always the first entry the list.
default?: any,
// INT/FLOAT options
min?: number,
max?: number,
step?: number,
// STRING options
multiline?: boolean,
}
// TODO if/when comfy refactors

View File

@@ -3,7 +3,7 @@ import type TypedEmitter from "typed-emitter";
import EventEmitter from "events";
import type { ComfyImageLocation } from "$lib/utils";
import type { SerializedLGraph, UUID } from "@litegraph-ts/core";
import type { SerializedLayoutState } from "./stores/layoutState";
import type { SerializedLayoutState } from "./stores/layoutStates";
import type { ComfyNodeDef } from "./ComfyNodeDef";
export type ComfyPromptRequest = {

View File

@@ -11,11 +11,12 @@
// notice - fade in works fine but don't add svelte's fade-out (known issue)
import {cubicIn} from 'svelte/easing';
import { flip } from 'svelte/animate';
import layoutState, { type ContainerLayout, type WidgetLayout, type IDragItem } from "$lib/stores/layoutState";
import { type ContainerLayout, type WidgetLayout, type IDragItem } from "$lib/stores/layoutStates";
import { startDrag, stopDrag } from "$lib/utils"
import { writable, type Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils";
export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null;
export let zIndex: number = 0;
export let classes: string[] = [];
@@ -59,6 +60,14 @@
navigator.vibrate(20)
$isOpen = e.detail
}
function _startDrag(e: MouseEvent | TouchEvent) {
startDrag(e, layoutState)
}
function _stopDrag(e: MouseEvent | TouchEvent) {
stopDrag(e, layoutState)
}
</script>
{#if container}
@@ -93,7 +102,7 @@
animate:flip={{duration:flipDurationMs}}
style={item?.attrs?.style || ""}
>
<WidgetContainer dragItem={item} zIndex={zIndex+1} {isMobile} />
<WidgetContainer {layoutState} dragItem={item} zIndex={zIndex+1} {isMobile} />
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='drag-item-shadow'/>
{/if}
@@ -101,10 +110,18 @@
{/each}
</div>
{#if isHidden(container) && edit}
<div class="handle handle-hidden" style="z-index: {zIndex+100}" class:hidden={!edit} />
<div class="handle handle-hidden"
style:z-index={zIndex+100}
class:hidden={!edit} />
{/if}
{#if showHandles}
<div class="handle handle-container" style="z-index: {zIndex+100}" data-drag-item-id={container.id} on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
<div class="handle handle-container"
style:z-index={zIndex+100}
data-drag-item-id={container.id}
on:mousedown={_startDrag}
on:touchstart={_startDrag}
on:mouseup={_stopDrag}
on:touchend={_stopDrag}/>
{/if}
</Accordion>
</Block>
@@ -112,7 +129,7 @@
<Block elem_classes={["gradio-accordion"]}>
<Accordion label={container.attrs.title} open={$isOpen} on:click={handleClick}>
{#each children.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item(item.id)}
<WidgetContainer dragItem={item} zIndex={zIndex+1} {isMobile} />
<WidgetContainer {layoutState} dragItem={item} zIndex={zIndex+1} {isMobile} />
{/each}
</Accordion>
</Block>

View File

@@ -10,11 +10,12 @@
// notice - fade in works fine but don't add svelte's fade-out (known issue)
import {cubicIn} from 'svelte/easing';
import { flip } from 'svelte/animate';
import layoutState, { type ContainerLayout, type WidgetLayout, type IDragItem } from "$lib/stores/layoutState";
import { type ContainerLayout, type WidgetLayout, type IDragItem, type WritableLayoutStateStore } from "$lib/stores/layoutStates";
import { startDrag, stopDrag } from "$lib/utils"
import type { Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils";
export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null;
export let zIndex: number = 0;
export let classes: string[] = [];
@@ -53,6 +54,14 @@
children = layoutState.updateChildren(container, evt.detail.items)
// Ensure dragging is stopped on drag finish
};
function _startDrag(e: MouseEvent | TouchEvent) {
startDrag(e, layoutState)
}
function _stopDrag(e: MouseEvent | TouchEvent) {
stopDrag(e, layoutState)
}
</script>
{#if container}
@@ -92,7 +101,7 @@
animate:flip={{duration:flipDurationMs}}
style={item?.attrs?.style || ""}
>
<WidgetContainer dragItem={item} zIndex={zIndex+1} {isMobile} />
<WidgetContainer {layoutState} dragItem={item} zIndex={zIndex+1} {isMobile} />
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='drag-item-shadow'/>
{/if}
@@ -100,10 +109,18 @@
{/each}
</div>
{#if isHidden(container) && edit}
<div class="handle handle-hidden" style="z-index: {zIndex+100}" class:hidden={!edit} />
<div class="handle handle-hidden"
style:z-index={zIndex+100}
class:hidden={!edit} />
{/if}
{#if showHandles}
<div class="handle handle-container" style="z-index: {zIndex+100}" data-drag-item-id={container.id} on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
<div class="handle handle-container"
style:z-index={zIndex+100}
data-drag-item-id={container.id}
on:mousedown={_startDrag}
on:touchstart={_startDrag}
on:mouseup={_stopDrag}
on:touchend={_stopDrag}/>
{/if}
</Block>
</div>

View File

@@ -1,44 +1,21 @@
<script lang="ts">
import { onMount } from "svelte";
import { get, writable, type Writable } from "svelte/store";
import { Pane, Splitpanes } from 'svelte-splitpanes';
import { Button } from "@gradio/button";
import { BlockTitle } from "@gradio/atoms";
import ComfyUIPane from "./ComfyUIPane.svelte";
import { ListIcon as List, ImageIcon as Image, SettingsIcon as Settings } from "svelte-feather-icons";
import ComfyApp, { type A1111PromptAndInfo, type SerializedAppState } from "./ComfyApp";
import { Checkbox, TextBox } from "@gradio/form"
import uiState from "$lib/stores/uiState";
import layoutState from "$lib/stores/layoutState";
import selectionState from "$lib/stores/selectionState";
import { ImageViewer } from "$lib/ImageViewer";
import { SvelteToast, toast } from '@zerodevx/svelte-toast'
import { LGraph } from "@litegraph-ts/core";
import LightboxModal from "./LightboxModal.svelte";
import ComfyQueue from "./ComfyQueue.svelte";
import ComfyProperties from "./ComfyProperties.svelte";
import queueState from "$lib/stores/queueState";
import ComfyUnlockUIButton from "./ComfyUnlockUIButton.svelte";
import ComfyGraphView from "./ComfyGraphView.svelte";
import { download, jsonToJsObject } from "$lib/utils";
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 Modal from "./Modal.svelte";
import ComfyBoxStdPrompt from "$lib/ComfyBoxStdPrompt";
import A1111PromptDisplay from "./A1111PromptDisplay.svelte";
import type { A1111ParsedInfotext } from "$lib/parseA1111";
import ComfyWorkflowsView from "./ComfyWorkflowsView.svelte";
export let app: ComfyApp = undefined;
let alreadySetup: Writable<boolean> = writable(false);
let a1111Prompt: Writable<A1111PromptAndInfo | null> = writable(null);
let mainElem: HTMLDivElement;
let props: ComfyProperties = undefined;
let containerElem: HTMLDivElement;
let resizeTimeout: NodeJS.Timeout | null;
let hasShownUIHelpToast: boolean = false;
let uiTheme: string = "gradio-dark";
let fileInput: HTMLInputElement = undefined;
let debugLayout: boolean = false;
const toastOptions = {
intro: { duration: 200 },
@@ -47,149 +24,11 @@
}
}
$: if(app) {
alreadySetup = app.alreadySetup;
a1111Prompt = app.a1111Prompt;
}
function refreshView(event?: Event) {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(app.resizeCanvas.bind(app), 250);
}
$: if (app?.lCanvas) {
app.lCanvas.allow_dragnodes = $uiState.uiUnlocked;
app.lCanvas.allow_interaction = $uiState.uiUnlocked;
if (!$uiState.uiUnlocked) {
app.lCanvas.deselectAllNodes();
$selectionState.currentSelectionNodes = []
}
}
$: if ($uiState.uiEditMode)
$selectionState.currentSelection = []
let graphSize = 0;
let graphTransitioning = false;
function queuePrompt() {
app.runDefaultQueueAction()
}
function toggleGraph() {
if (graphSize == 0) {
graphSize = 50;
app.resizeCanvas();
}
else {
graphSize = 0;
}
}
let propsSidebarSize = 0;
function toggleProps() {
if (propsSidebarSize == 0) {
propsSidebarSize = 15;
app.resizeCanvas();
}
else {
propsSidebarSize = 0;
}
}
let queueSidebarSize = 20;
function toggleQueue() {
if (queueSidebarSize == 0) {
queueSidebarSize = 20;
app.resizeCanvas();
}
else {
queueSidebarSize = 0;
}
}
function doSave(): void {
if (!app?.lGraph)
return;
app.querySave()
}
function doLoad(): void {
if (!app?.lGraph || !fileInput)
return;
fileInput.click();
}
function loadWorkflow(): void {
app.handleFile(fileInput.files[0]);
fileInput.files = null;
}
function doSaveLocal(): void {
if (!app?.lGraph)
return;
app.saveStateToLocalStorage();
}
async function doLoadDefault() {
var confirmed = confirm("Are you sure you want to clear the current workflow and load the default graph?");
if (confirmed) {
await app.initDefaultGraph();
}
}
function doClear(): void {
var confirmed = confirm("Are you sure you want to clear the current workflow?");
if (confirmed) {
app.clear();
}
}
$: if ($uiState.uiUnlocked && !hasShownUIHelpToast) {
hasShownUIHelpToast = true;
notify("Right-click to open context menu.")
}
if (debugLayout) {
layoutState.subscribe(s => {
console.warn("UPDATESTATE", s)
})
}
$: if (containerElem) {
const canvas = containerElem.querySelector<HTMLDivElement>("#graph-canvas")
if (canvas) {
const paneNode = canvas.closest(".splitpanes__pane")
if (paneNode) {
(paneNode as HTMLElement).ontransitionstart = () => {
graphTransitioning = true
}
(paneNode as HTMLElement).ontransitionend = () => {
graphTransitioning = false
app.resizeCanvas()
}
}
}
}
onMount(async () => {
await app.setup();
// await import('../../scss/ux.scss');
refreshView();
})
async function doRefreshCombos() {
await app.refreshComboInNodes(true)
}
$: if (uiTheme === "gradio-dark") {
document.getElementById("app-root").classList.add("dark")
}
@@ -197,9 +36,11 @@
document.getElementById("app-root").classList.remove("dark")
}
let showModal: boolean = false;
$: showModal = $a1111Prompt != null
// let showModal: boolean = false;
//
// $: showModal = $a1111Prompt != null
//
// let selectedTab
</script>
<svelte:head>
@@ -208,6 +49,7 @@
{/if}
</svelte:head>
<!--
<Modal bind:showModal on:close={() => ($a1111Prompt = null)}>
<div slot="header" class="prompt-modal-header">
<h1 style="padding-bottom: 1rem;">A1111 Prompt Details</h1>
@@ -219,187 +61,27 @@
</Button>
</div>
</Modal>
-->
<div id="main" class:dark={uiTheme === "gradio-dark"}>
<div id="container" bind:this={containerElem}>
<Splitpanes theme="comfy" on:resize={refreshView}>
<Pane bind:size={propsSidebarSize}>
<div class="sidebar-wrapper pane-wrapper">
<ComfyProperties bind:this={props} />
</div>
</Pane>
<Pane>
<Splitpanes theme="comfy" on:resize={refreshView} horizontal="{true}">
<Pane>
<ComfyUIPane {app} />
</Pane>
<Pane bind:size={graphSize}>
<ComfyGraphView {app} transitioning={graphTransitioning} />
</Pane>
</Splitpanes>
</Pane>
<Pane bind:size={queueSidebarSize}>
<div class="sidebar-wrapper pane-wrapper">
<ComfyQueue {app} />
</div>
</Pane>
</Splitpanes>
</div>
<div id="bottombar">
<div class="left">
{#if $layoutState.attrs.queuePromptButtonName != ""}
<Button variant="primary" disabled={!$alreadySetup} on:click={queuePrompt}>
{$layoutState.attrs.queuePromptButtonName}
</Button>
{/if}
<Button variant="secondary" disabled={!$alreadySetup} on:click={toggleGraph}>
Toggle Graph
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={toggleProps}>
Toggle Props
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={toggleQueue}>
Toggle Queue
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doSave}>
Save
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doSaveLocal}>
Save Local
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doLoad}>
Load
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doClear}>
Clear
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doLoadDefault}>
Load Default
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doRefreshCombos}>
🔄
</Button>
<!-- <Checkbox label="Lock Nodes" bind:value={$uiState.nodesLocked}/>
<Checkbox label="Disable Interaction" bind:value={$uiState.graphLocked}/> -->
<span style="display: inline-flex !important">
<Checkbox label="Auto-Add UI" bind:value={$uiState.autoAddUI}/>
</span>
<span class="label" for="ui-edit-mode">
<BlockTitle>UI Edit mode</BlockTitle>
<select id="ui-edit-mode" name="ui-edit-mode" bind:value={$uiState.uiEditMode}>
<option value="widgets">Widgets</option>
</select>
</span>
<span class="label" for="ui-theme">
<BlockTitle>Theme</BlockTitle>
<select id="ui-theme" name="ui-theme" bind:value={uiTheme}>
<option value="gradio-dark">Gradio Dark</option>
<option value="gradio-light">Gradio Light</option>
<option value="anapnoe">Anapnoe</option>
</select>
</span>
</div>
<div class="right">
<ComfyUnlockUIButton bind:toggled={$uiState.uiUnlocked} />
</div>
<div id="container">
<Sidebar selected="generate">
<SidebarItem id="generate" name="Generate" icon={Image}>
<ComfyWorkflowsView {app} {uiTheme} />
</SidebarItem>
<SidebarItem id="settings" name="Settings" icon={Settings}>
</SidebarItem>
</Sidebar>
</div>
<LightboxModal />
<input bind:this={fileInput} id="comfy-file-input" type="file" accept=".json" on:change={loadWorkflow} />
</div>
<SvelteToast options={toastOptions} />
<style lang="scss">
$bottom-bar-height: 70px;
#container {
height: calc(100vh - $bottom-bar-height);
max-width: 100vw;
display: grid;
width: 100%;
}
#comfy-content {
grid-area: content;
height: 100vh;
}
#bottombar {
padding-top: 0.5em;
display: flex;
width: 100%;
gap: var(--layout-gap);
padding-left: 1em;
padding-right: 1em;
margin-top: auto;
overflow-x: auto;
height: $bottom-bar-height;
> .left {
flex-shrink: 0;
}
> .right {
margin-left: auto
}
}
.sidebar-wrapper {
width: 100%;
height: 100%;
}
:global(html, body) {
width: 100%;
height: 100%;
margin: 0px;
font-family: Arial;
}
:global(.splitpanes.comfy>.splitpanes__splitter) {
background: var(--comfy-splitpanes-background-fill);
&:hover:not([disabled]) {
background: var(--comfy-splitpanes-background-fill-hover);
}
&:active:not([disabled]) {
background: var(--comfy-splitpanes-background-fill-active);
}
}
$splitter-size: 1rem;
:global(.splitpanes.comfy.splitpanes--horizontal>.splitpanes__splitter) {
min-height: $splitter-size;
cursor: row-resize;
}
:global(.splitpanes.comfy.splitpanes--vertical>.splitpanes__splitter) {
min-width: $splitter-size;
cursor: col-resize;
}
:global(.splitpanes.comfy) {
max-height: calc(100vh - $bottom-bar-height);
max-width: 100vw;
}
:global(.splitpanes__pane) {
box-shadow: 0 0 3px rgba(0, 0, 0, .2) inset;
justify-content: center;
align-items: center;
display: flex;
position: relative;
}
label.label > :global(span) {
top: 20%;
}
span.left {
right: 0px;
}
#comfy-file-input {
display: none;
display: relative;
width: 100%;
}
</style>

View File

@@ -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, type SlotIndex } 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, 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 } from "$lib/pnginfo";
@@ -21,8 +21,7 @@ import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import queueState from "$lib/stores/queueState";
import { type SvelteComponentDev } from "svelte/internal";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import type { SerializedLayoutState } from "$lib/stores/layoutState";
import layoutState from "$lib/stores/layoutState";
import type { LayoutState, SerializedLayoutState, WritableLayoutStateStore } from "$lib/stores/layoutStates";
import { toast } from '@zerodevx/svelte-toast'
import ComfyGraph from "$lib/ComfyGraph";
import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode";
@@ -33,7 +32,7 @@ import { download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range, wor
import notify from "$lib/notify";
import configState from "$lib/stores/configState";
import { blankGraph } from "$lib/defaultGraph";
import type { ComfyExecutionResult } from "$lib/utils";
import type { SerializedPromptOutput } from "$lib/utils";
import ComfyPromptSerializer, { UpstreamNodeLocator, isActiveBackendNode } from "./ComfyPromptSerializer";
import { iterateNodeDefInputs, type ComfyNodeDef, isBackendNodeDefInputType, iterateNodeDefOutputs } from "$lib/ComfyNodeDef";
import { ComfyComboNode } from "$lib/nodes/widgets";
@@ -41,6 +40,10 @@ import parseA1111, { type A1111ParsedInfotext } from "$lib/parseA1111";
import convertA1111ToStdPrompt from "$lib/convertA1111ToStdPrompt";
import type { ComfyBoxStdPrompt } from "$lib/ComfyBoxStdPrompt";
import ComfyBoxStdPromptSerializer from "$lib/ComfyBoxStdPromptSerializer";
import selectionState from "$lib/stores/selectionState";
import layoutStates from "$lib/stores/layoutStates";
import { ComfyWorkflow, type WorkflowAttributes, type WorkflowInstID } from "$lib/stores/workflowState";
import workflowState from "$lib/stores/workflowState";
export const COMFYBOX_SERIAL_VERSION = 1;
@@ -51,12 +54,11 @@ if (typeof window !== "undefined") {
/*
* Queued prompt that hasn't been sent to the backend yet.
* TODO: Assumes the currently active graph will be serialized, needs to change
* for multiple loaded workflow support
*/
type QueueItem = {
type PromptQueueItem = {
num: number,
batchCount: number
workflow: ComfyWorkflow
}
export type A1111PromptAndInfo = {
@@ -78,6 +80,8 @@ export type SerializedAppState = {
commitHash?: string,
/** Graph state */
workflow: SerializedLGraph,
/** Workflow attributes */
attrs: WorkflowAttributes,
/** UI state */
layout: SerializedLayoutState,
/** Position/offset of the canvas at the time of saving */
@@ -111,7 +115,7 @@ export type SerializedPrompt = {
/*
* Outputs for each node.
*/
export type SerializedPromptOutputs = Record<ComfyNodeID, ComfyExecutionResult>
export type SerializedPromptOutputs = Record<ComfyNodeID, SerializedPromptOutput>
export type Progress = {
value: number,
@@ -128,15 +132,19 @@ type BackendComboNode = {
backendNode: ComfyBackendNode
}
type CanvasState = {
canvasEl: HTMLCanvasElement,
canvasCtx: CanvasRenderingContext2D,
canvas: ComfyGraphCanvas,
}
export default class ComfyApp {
api: ComfyAPI;
rootEl: HTMLDivElement | null = null;
canvasEl: HTMLCanvasElement | null = null;
canvasCtx: CanvasRenderingContext2D | null = null;
lGraph: ComfyGraph | null = null;
lCanvas: ComfyGraphCanvas | null = null;
dropZone: HTMLElement | null = null;
nodeOutputs: Record<string, any> = {};
shiftDown: boolean = false;
ctrlDown: boolean = false;
@@ -144,7 +152,7 @@ export default class ComfyApp {
alreadySetup: Writable<boolean> = writable(false);
a1111Prompt: Writable<A1111PromptAndInfo | null> = writable(null);
private queueItems: QueueItem[] = [];
private queueItems: PromptQueueItem[] = [];
private processingQueue: boolean = false;
private promptSerializer: ComfyPromptSerializer;
private stdPromptSerializer: ComfyBoxStdPromptSerializer;
@@ -157,7 +165,7 @@ export default class ComfyApp {
async setup(): Promise<void> {
if (get(this.alreadySetup)) {
console.error("Already setup")
console.log("Already setup")
return;
}
@@ -165,7 +173,6 @@ export default class ComfyApp {
this.rootEl = document.getElementById("app-root") as HTMLDivElement;
this.canvasEl = document.getElementById("graph-canvas") as HTMLCanvasElement;
this.lGraph = new ComfyGraph();
this.lCanvas = new ComfyGraphCanvas(this, this.canvasEl);
this.canvasCtx = this.canvasEl.getContext("2d");
@@ -174,17 +181,13 @@ export default class ComfyApp {
this.lCanvas.allow_interaction = uiUnlocked;
// await this.#invokeExtensionsAsync("init");
await this.registerNodes();
const defs = await this.api.getNodeDefs();
await this.registerNodes(defs);
// Load previous workflow
let restored = false;
try {
const json = localStorage.getItem("workflow");
if (json) {
const state = JSON.parse(json) as SerializedAppState;
await this.deserialize(state)
restored = true;
}
restored = await this.loadStateFromLocalStorage(defs);
} catch (err) {
console.error("Error loading previous workflow", err);
notify(`Error loading previous workflow:\n${err}`, { type: "error", timeout: null })
@@ -192,7 +195,7 @@ export default class ComfyApp {
// We failed to restore a workflow so load the default
if (!restored) {
await this.initDefaultGraph();
await this.initDefaultWorkflow(defs);
}
// Save current workflow automatically
@@ -225,12 +228,37 @@ export default class ComfyApp {
this.lCanvas.draw(true, true);
}
serialize(workflow: ComfyWorkflow): SerializedAppState {
const layoutState = layoutStates.getLayout(workflow.id);
if (layoutState == null)
throw new Error("Workflow has no layout!")
const { graph, layout, attrs } = workflow.serialize(layoutState);
const canvas = this.lCanvas.serialize();
return {
createdBy: "ComfyBox",
version: COMFYBOX_SERIAL_VERSION,
commitHash: __GIT_COMMIT_HASH__,
workflow: graph,
attrs,
layout,
canvas
}
}
saveStateToLocalStorage() {
try {
uiState.update(s => { s.isSavingToLocalStorage = true; return s; })
const savedWorkflow = this.serialize();
const json = JSON.stringify(savedWorkflow);
localStorage.setItem("workflow", json)
const state = get(workflowState)
const workflows = state.openedWorkflows
const savedWorkflows = workflows.map(w => this.serialize(w));
const activeWorkflowIndex = workflows.findIndex(w => state.activeWorkflowID === w.id);
const json = JSON.stringify({ workflows: savedWorkflows, activeWorkflowIndex });
localStorage.setItem("workflows", json)
for (const workflow of workflows)
workflow.isModified = false;
workflowState.set(get(workflowState));
notify("Saved to local storage.")
}
catch (err) {
@@ -241,13 +269,33 @@ export default class ComfyApp {
}
}
async loadStateFromLocalStorage(defs: Record<ComfyNodeID, ComfyNodeDef>): Promise<boolean> {
const json = localStorage.getItem("workflows");
if (!json) {
return false
}
const state = JSON.parse(json);
if (!("workflows" in state))
return false;
const workflows = state.workflows as SerializedAppState[];
for (const workflow of workflows) {
await this.openWorkflow(workflow, defs)
}
if (typeof state.activeWorkflowIndex === "number") {
workflowState.setActiveWorkflow(this.lCanvas, state.activeWorkflowIndex);
selectionState.clear();
}
return true;
}
static node_type_overrides: Record<string, typeof ComfyBackendNode> = {}
static widget_type_overrides: Record<string, typeof SvelteComponentDev> = {}
private async registerNodes() {
// Load node definitions from the backend
const defs = await this.api.getNodeDefs();
private async registerNodes(defs: Record<ComfyNodeID, ComfyNodeDef>) {
// Register a node for each definition
for (const [nodeId, nodeDef] of Object.entries(defs)) {
const typeOverride = ComfyApp.node_type_overrides[nodeId]
@@ -351,8 +399,12 @@ export default class ComfyApp {
} catch (error) { }
}
if (workflow && workflow.version && workflow.nodes && workflow.extra) {
this.loadGraphData(workflow);
if (workflow && workflow.createdBy === "ComfyBox") {
this.openWorkflow(workflow);
}
else {
// TODO handle vanilla workflows
throw new Error("Workflow was not in ComfyBox format!")
}
});
}
@@ -375,21 +427,29 @@ export default class ComfyApp {
this.api.addEventListener("progress", (progress: Progress) => {
queueState.progressUpdated(progress);
this.lGraph.setDirtyCanvas(true, false);
workflowState.getActiveWorkflow()?.graph?.setDirtyCanvas(true, false); // TODO PromptID
});
this.api.addEventListener("executing", (promptID: PromptID | null, nodeID: ComfyNodeID | null) => {
queueState.executingUpdated(promptID, nodeID);
this.lGraph.setDirtyCanvas(true, false);
const queueEntry = queueState.executingUpdated(promptID, nodeID);
if (queueEntry != null) {
const workflow = workflowState.getWorkflow(queueEntry.workflowID);
workflow?.graph?.setDirtyCanvas(true, false);
}
});
this.api.addEventListener("executed", (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => {
this.nodeOutputs[nodeID] = output;
const node = this.lGraph.getNodeByIdRecursive(nodeID) as ComfyGraphNode;
this.api.addEventListener("executed", (promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutput) => {
const queueEntry = queueState.onExecuted(promptID, nodeID, output)
if (queueEntry != null) {
const workflow = workflowState.getWorkflow(queueEntry.workflowID);
if (workflow != null) {
workflow.graph.setDirtyCanvas(true, false);
const node = workflow.graph.getNodeByIdRecursive(nodeID) as ComfyGraphNode;
if (node?.onExecuted) {
node.onExecuted(output);
}
queueState.onExecuted(promptID, nodeID, output)
}
}
});
this.api.addEventListener("execution_start", (promptID: PromptID) => {
@@ -456,49 +516,49 @@ export default class ComfyApp {
setColor(BuiltInSlotType.ACTION, "lightseagreen")
}
serialize(): SerializedAppState {
const graph = this.lGraph;
const serializedGraph = graph.serialize()
const serializedLayout = layoutState.serialize()
const serializedCanvas = this.lCanvas.serialize();
return {
createdBy: "ComfyBox",
version: COMFYBOX_SERIAL_VERSION,
workflow: serializedGraph,
layout: serializedLayout,
canvas: serializedCanvas
}
}
async deserialize(data: SerializedAppState) {
async openWorkflow(data: SerializedAppState, refreshCombos: boolean | Record<string, ComfyNodeDef> = true): Promise<ComfyWorkflow> {
if (data.version !== COMFYBOX_SERIAL_VERSION) {
throw `Invalid ComfyBox saved data format: ${data.version}`
}
this.clean();
// 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
// want to restore the layoutState ourselves
layoutState.onStartConfigure();
this.loadGraphData(data.workflow)
// Now restore the layout
// Subsequent added nodes will add the UI data to layoutState
layoutState.deserialize(data.layout, this.lGraph)
const workflow = workflowState.openWorkflow(this.lCanvas, data);
// Restore canvas offset/zoom
this.lCanvas.deserialize(data.canvas)
await this.refreshComboInNodes();
this.lGraph.start();
this.lGraph.eventBus.on("afterExecute", () => this.lCanvas.draw(true))
if (refreshCombos) {
let defs = null;
if (typeof refreshCombos === "object")
defs = refreshCombos;
await this.refreshComboInNodes(workflow, defs);
}
async initDefaultGraph() {
return workflow;
}
setActiveWorkflow(id: WorkflowInstID) {
const index = get(workflowState).openedWorkflows.findIndex(w => w.id === id)
if (index === -1)
return;
workflowState.setActiveWorkflow(this.lCanvas, index);
selectionState.clear();
}
createNewWorkflow() {
workflowState.createNewWorkflow(this.lCanvas, undefined, true);
selectionState.clear();
}
closeWorkflow(id: WorkflowInstID) {
const index = get(workflowState).openedWorkflows.findIndex(w => w.id === id)
if (index === -1)
return;
workflowState.closeWorkflow(this.lCanvas, index);
selectionState.clear();
}
async initDefaultWorkflow(defs?: Record<string, ComfyNodeDef>) {
let state = null;
try {
const graphResponse = await fetch("/workflows/defaultWorkflow.json");
@@ -509,50 +569,14 @@ export default class ComfyApp {
notify(`Failed to load default graph: ${error}`, { type: "error" })
state = structuredClone(blankGraph)
}
await this.deserialize(state)
}
/**
* Populates the graph with the specified workflow data
* @param {*} graphData A serialized graph object
*/
loadGraphData(graphData: SerializedLGraph) {
this.clean();
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
for (let n of graphData.nodes) {
if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
}
this.lGraph.configure(graphData);
for (const node of this.lGraph._nodes) {
const size = node.computeSize();
size[0] = Math.max(node.size[0], size[0]);
size[1] = Math.max(node.size[1], size[1]);
node.size = size;
// this.#invokeExtensions("loadedGraphNode", node);
}
await this.openWorkflow(state, defs)
}
clear() {
this.clean();
const blankGraph: SerializedLGraph = {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0
}
layoutState.onStartConfigure();
this.lCanvas.closeAllSubgraphs();
this.lGraph.configure(blankGraph)
layoutState.initDefaultLayout();
workflowState.closeAllWorkflows(this.lCanvas);
uiState.update(s => {
s.uiUnlocked = true;
s.uiEditMode = "widgets";
@@ -561,13 +585,17 @@ export default class ComfyApp {
}
runDefaultQueueAction() {
for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
const workflow = workflowState.getActiveWorkflow();
if (workflow == null)
return;
for (const node of workflow.graph.iterateNodesInOrderRecursive()) {
if ("onDefaultQueueAction" in node) {
(node as ComfyGraphNode).onDefaultQueueAction()
}
}
if (get(layoutState).attrs.queuePromptButtonRunWorkflow) {
if (workflow.attrs.queuePromptButtonRunWorkflow) {
// Hold control to queue at the front
const num = this.ctrlDown ? -1 : 0;
this.queuePrompt(num, 1);
@@ -575,6 +603,12 @@ export default class ComfyApp {
}
querySave() {
const workflow = workflowState.getActiveWorkflow();
if (workflow == null) {
notify("No active workflow!", { type: "error" })
return;
}
const promptFilename = get(configState).promptForWorkflowName;
let filename = "workflow.json";
@@ -592,10 +626,13 @@ export default class ComfyApp {
}
const indent = 2
const json = JSON.stringify(this.serialize(), null, indent)
const json = JSON.stringify(this.serialize(workflow), null, indent)
download(filename, json, "application/json")
workflow.isModified = false;
workflowState.set(get(workflowState));
console.debug(jsonToJsObject(json))
}
@@ -603,12 +640,18 @@ export default class ComfyApp {
* Converts the current graph workflow for sending to the API
* @returns The workflow and node links
*/
graphToPrompt(tag: string | null = null): SerializedPrompt {
return this.promptSerializer.serialize(this.lGraph, tag)
graphToPrompt(workflow: ComfyWorkflow, tag: string | null = null): SerializedPrompt {
return this.promptSerializer.serialize(workflow.graph, tag)
}
async queuePrompt(num: number, batchCount: number = 1, tag: string | null = null) {
this.queueItems.push({ num, batchCount });
const activeWorkflow = workflowState.getActiveWorkflow();
if (activeWorkflow == null) {
notify("No workflow is opened!", { type: "error" })
return;
}
this.queueItems.push({ num, batchCount, workflow: activeWorkflow });
// Only have one action process the items so each one gets a unique seed correctly
if (this.processingQueue) {
@@ -619,13 +662,15 @@ export default class ComfyApp {
tag = null;
this.processingQueue = true;
let workflow;
try {
while (this.queueItems.length) {
({ num, batchCount } = this.queueItems.pop());
({ num, batchCount, workflow } = this.queueItems.pop());
console.debug(`Queue get! ${num} ${batchCount} ${tag}`);
const thumbnails = []
for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
for (const node of workflow.graph.iterateNodesInOrderRecursive()) {
if (node.mode !== NodeMode.ALWAYS
|| (tag != null
&& Array.isArray(node.properties.tags)
@@ -640,7 +685,7 @@ export default class ComfyApp {
}
for (let i = 0; i < batchCount; i++) {
for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
for (const node of workflow.graph.iterateNodesInOrderRecursive()) {
if (node.mode !== NodeMode.ALWAYS)
continue;
@@ -649,9 +694,9 @@ export default class ComfyApp {
}
}
const p = this.graphToPrompt(tag);
const l = layoutState.serialize();
console.debug(graphToGraphVis(this.lGraph))
const p = this.graphToPrompt(workflow, tag);
const l = workflow.layout.serialize();
console.debug(graphToGraphVis(workflow.graph))
console.debug(promptToGraphVis(p))
const stdPrompt = this.stdPromptSerializer.serialize(p);
@@ -681,7 +726,7 @@ export default class ComfyApp {
error = response.error;
}
else {
queueState.afterQueued(response.promptID, num, p.output, extraData)
queueState.afterQueued(workflow.id, response.promptID, num, p.output, extraData)
}
} catch (err) {
error = err?.toString();
@@ -690,13 +735,13 @@ export default class ComfyApp {
if (error != null) {
const mes: string = error;
notify(`Error queuing prompt:\n${mes}`, { type: "error" })
console.error(graphToGraphVis(this.lGraph))
console.error(graphToGraphVis(workflow.graph))
console.error(promptToGraphVis(p))
console.error("Error queuing prompt", error, num, p)
break;
}
for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
for (const node of workflow.graph.iterateNodesInOrderRecursive()) {
if ("afterQueued" in node) {
(node as ComfyGraphNode).afterQueued(p, tag);
}
@@ -719,7 +764,7 @@ export default class ComfyApp {
const pngInfo = await getPngMetadata(file);
if (pngInfo) {
if (pngInfo.comfyBoxConfig) {
this.deserialize(JSON.parse(pngInfo.comfyBoxConfig));
await this.openWorkflow(JSON.parse(pngInfo.comfyBoxConfig));
} else if (pngInfo.parameters) {
const parsed = parseA1111(pngInfo.parameters)
if ("error" in parsed) {
@@ -741,8 +786,8 @@ export default class ComfyApp {
}
} else if (file.type === "application/json" || file.name.endsWith(".json")) {
const reader = new FileReader();
reader.onload = () => {
this.deserialize(JSON.parse(reader.result as string));
reader.onload = async () => {
await this.openWorkflow(JSON.parse(reader.result as string));
};
reader.readAsText(file);
}
@@ -761,8 +806,15 @@ export default class ComfyApp {
/**
* Refresh combo list on whole nodes
*/
async refreshComboInNodes(flashUI: boolean = false) {
const defs = await this.api.getNodeDefs();
async refreshComboInNodes(workflow?: ComfyWorkflow, defs?: Record<string, ComfyNodeDef>, flashUI: boolean = false) {
workflow ||= workflowState.getActiveWorkflow();
if (workflow == null) {
notify("No active workflow!", { type: "error" })
return
}
if (defs == null)
defs = await this.api.getNodeDefs();
const isComfyComboNode = (node: LGraphNode): node is ComfyComboNode => {
return node
@@ -805,7 +857,7 @@ export default class ComfyApp {
return result
}
for (const node of this.lGraph.iterateNodesInOrderRecursive()) {
for (const node of workflow.graph.iterateNodesInOrderRecursive()) {
if (!isActiveBackendNode(node))
continue;
@@ -827,7 +879,7 @@ export default class ComfyApp {
console.debug("[refreshComboInNodes] found:", backendUpdatedCombos.length, backendUpdatedCombos)
// Mark combo nodes without backend configs as being loaded already.
for (const node of this.lGraph.iterateNodesOfClassRecursive(ComfyComboNode)) {
for (const node of workflow.graph.iterateNodesOfClassRecursive(ComfyComboNode)) {
if (backendUpdatedCombos[node.id] != null) {
continue;
}
@@ -859,12 +911,15 @@ export default class ComfyApp {
// Load definitions from the backend.
for (const { comboNode, comfyInput, backendNode } of Object.values(backendUpdatedCombos)) {
const def = defs[backendNode.type];
const rawValues = def["input"]["required"][comfyInput.name][0];
const [rawValues, opts] = def.input.required[comfyInput.name];
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[], true)
const values = rawValues as string[]
const defaultValue = rawValues[0];
comboNode.formatValues(values, defaultValue, true)
if (!rawValues?.includes(get(comboNode.value))) {
comboNode.setValue(rawValues[0], comfyInput.config.defaultValue)
}
@@ -875,7 +930,6 @@ export default class ComfyApp {
* Clean current state
*/
clean() {
this.nodeOutputs = {};
this.a1111Prompt.set(null);
}
}

View File

@@ -35,7 +35,18 @@ export function isActiveBackendNode(node: LGraphNode, tag: string | null = null)
if (!(node as any).isBackendNode)
return false;
return isActiveNode(node, tag);
if (!isActiveNode(node, tag))
return false;
// Make sure this node is not contained in an inactive subgraph, even if the
// node itself is considered active
if (node.graph._is_subgraph) {
const isInsideDisabledSubgraph = Array.from(node.iterateParentSubgraphNodes()).some(n => !isActiveNode(n, tag))
if (isInsideDisabledSubgraph)
return false;
}
return true;
}
export class UpstreamNodeLocator {
@@ -166,7 +177,7 @@ export default class ComfyPromptSerializer {
// 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) {
if (inputNode && inputNode.mode !== NodeMode.ALWAYS) {
console.debug("Skipping inactive node", inputNode)
continue;
}
@@ -248,6 +259,8 @@ export default class ComfyPromptSerializer {
const inputs = this.serializeInputValues(node);
const links = this.serializeBackendLinks(node, tag);
console.warn("OUTPUT", node.id, node.comfyClass, node.mode)
output[String(node.id)] = {
inputs: { ...inputs, ...links },
class_type: node.comfyClass,

View File

@@ -2,29 +2,33 @@
import { Block, BlockTitle } from "@gradio/atoms";
import { TextBox, Checkbox } from "@gradio/form";
import { LGraphNode } from "@litegraph-ts/core"
import layoutState, { type IDragItem, type WidgetLayout, ALL_ATTRIBUTES, type AttributesSpec } from "$lib/stores/layoutState"
import { type IDragItem, type WidgetLayout, ALL_ATTRIBUTES, type AttributesSpec, type WritableLayoutStateStore } from "$lib/stores/layoutStates"
import uiState from "$lib/stores/uiState"
import workflowState from "$lib/stores/workflowState"
import layoutStates from "$lib/stores/layoutStates"
import selectionState from "$lib/stores/selectionState"
import { get, type Writable, writable } from "svelte/store"
import ComfyNumberProperty from "./ComfyNumberProperty.svelte";
import ComfyComboProperty from "./ComfyComboProperty.svelte";
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import type { ComfyWorkflow } from "$lib/stores/workflowState";
export let workflow: ComfyWorkflow | null;
let layoutState: WritableLayoutStateStore | null = null
$: layoutState = workflow?.layout
let target: IDragItem | null = null;
let node: LGraphNode | null = null;
let attrsChanged: Writable<number> | null = null;
let refreshPropsPanel: Writable<number> | null
$: refreshPropsPanel = $layoutState.refreshPropsPanel;
$: if ($selectionState.currentSelection.length > 0) {
$: if (layoutState) {
if ($selectionState.currentSelection.length > 0) {
node = null;
const targetId = $selectionState.currentSelection.slice(-1)[0]
const entry = $layoutState.allItems[targetId]
if (entry != null) {
target = entry.dragItem
attrsChanged = target.attrsChanged;
if (target.type === "widget") {
node = (target as WidgetLayout).node
}
@@ -33,12 +37,15 @@
else if ($selectionState.currentSelectionNodes.length > 0) {
target = null;
node = $selectionState.currentSelectionNodes[0]
attrsChanged = null;
}
else {
target = null
node = null;
attrsChanged = null;
}
}
else {
target = null;
node = null;
}
$: if (target) {
@@ -55,7 +62,7 @@
let value = spec.defaultValue;
target.attrs[spec.name] = value;
if (spec.refreshPanelOnChange)
$refreshPropsPanel += 1;
doRefreshPanel();
}
}
}
@@ -123,7 +130,10 @@
if (spec.location !== "workflow")
return false;
return spec.name in $layoutState.attrs
if (workflow == null)
return false;
return spec.name in workflow.attrs
}
function getAttribute(target: IDragItem, spec: AttributesSpec): any {
@@ -162,6 +172,8 @@
if (spec.refreshPanelOnChange) {
doRefreshPanel()
}
workflow.notifyModified()
}
function getProperty(node: LGraphNode, spec: AttributesSpec) {
@@ -197,6 +209,8 @@
if (spec.refreshPanelOnChange)
doRefreshPanel()
workflow.notifyModified()
}
function getVar(node: LGraphNode, spec: AttributesSpec) {
@@ -233,10 +247,15 @@
if (spec.refreshPanelOnChange) {
doRefreshPanel()
}
workflow.notifyModified();
}
function getWorkflowAttribute(spec: AttributesSpec): any {
let value = $layoutState.attrs[spec.name]
if (workflow == null)
throw new Error("Active workflow is null!");
let value = workflow.attrs[spec.name]
if (value == null)
value = spec.defaultValue
else if (spec.serialize)
@@ -249,23 +268,28 @@
if (!spec.editable)
return;
if (workflow == null)
throw new Error("Active workflow is null!");
const name = spec.name
// console.warn("[ComfyProperties] updateWorkflowAttribute", name, value)
const prevValue = value
$layoutState.attrs[name] = value
$layoutState = $layoutState
workflow.attrs[name] = value
$workflowState = $workflowState;
if (spec.onChanged)
spec.onChanged($layoutState, value, prevValue)
if (spec.refreshPanelOnChange)
doRefreshPanel()
workflow.notifyModified();
}
function doRefreshPanel() {
console.warn("[ComfyProperties] doRefreshPanel")
$refreshPropsPanel += 1;
$layoutStates.refreshPropsPanel += 1;
}
</script>
@@ -282,7 +306,9 @@
</div>
</div>
<div class="props-entries">
{#key $refreshPropsPanel}
{#if workflow != null && layoutState != null}
{#key workflow.id}
{#key $layoutStates.refreshPropsPanel}
{#each ALL_ATTRIBUTES as category(category.categoryName)}
<div class="category-name">
<span>
@@ -448,6 +474,8 @@
{/if}
{/each}
{/each}
{/key}
{/key}
{/if}
</div>
</div>

View File

@@ -13,6 +13,7 @@
import { tick } from "svelte";
import Modal from "./Modal.svelte";
import DropZone from "./DropZone.svelte";
import workflowState from "$lib/stores/workflowState";
export let app: ComfyApp;
@@ -71,10 +72,17 @@
const subgraphs: string[] | null = entry.extraData?.extra_pnginfo?.comfyBoxSubgraphs;
let message = "Prompt";
if (entry.workflowID != null) {
const workflow = workflowState.getWorkflow(entry.workflowID);
if (workflow != null && workflow.attrs.title) {
message = `Workflow: ${workflow.attrs.title}`
}
if (subgraphs?.length > 0)
message = `Prompt: ${subgraphs.join(', ')}`
message += ` (${subgraphs.join(', ')})`
}
let submessage = `Nodes: ${Object.keys(entry.prompt).length}`
if (Object.keys(entry.outputs).length > 0) {
const imageCount = Object.values(entry.outputs).flatMap(o => o.images).length
submessage = `Images: ${imageCount}`
@@ -84,7 +92,7 @@
entry,
message,
submessage,
dateStr,
date: dateStr,
status: "pending",
images: []
}
@@ -387,7 +395,7 @@
&.all_cached, &.interrupted {
filter: brightness(80%);
color: var(--neutral-300);
color: var(--comfy-accent-soft);
}
}

View File

@@ -1,34 +1,23 @@
<script lang="ts">
import { tick } from 'svelte'
import { get } from "svelte/store";
import { LGraphNode, LGraph } from "@litegraph-ts/core";
import type { IWidget } from "@litegraph-ts/core";
import ComfyApp from "./ComfyApp";
import type { SerializedPanes } from "./ComfyApp"
import WidgetContainer from "./WidgetContainer.svelte";
import layoutState, { type ContainerLayout, type DragItem, type IDragItem } from "$lib/stores/layoutState";
import { type ContainerLayout, type IDragItem, type WritableLayoutStateStore } from "$lib/stores/layoutStates";
import uiState from "$lib/stores/uiState";
import selectionState from "$lib/stores/selectionState";
import Menu from './menu/Menu.svelte';
import MenuOption from './menu/MenuOption.svelte';
import MenuDivider from './menu/MenuDivider.svelte';
import Icon from './menu/Icon.svelte'
import type { ComfyWorkflow } from "$lib/stores/workflowState";
export let app: ComfyApp;
export let workflow: ComfyWorkflow;
let layoutState: WritableLayoutStateStore | null;
$: layoutState = workflow?.layout;
let root: IDragItem | null;
let dragConfigured: boolean = false;
/*
* Serialize UI panel order so it can be restored when workflow is loaded
*/
export function serialize(): any {
// TODO
}
export function restore(panels: SerializedPanes) {
// TODO
}
function moveTo(delta: number | ((cur: number, total: number) => number)) {
const dragItemID = $selectionState.currentSelection[0];
@@ -149,9 +138,11 @@
}
</script>
<div id="comfy-ui-panes" on:contextmenu={onRightClick}>
<WidgetContainer bind:dragItem={root} classes={["root-container"]} />
{#if layoutState != null}
<div id="comfy-workflow-view" on:contextmenu={onRightClick}>
<WidgetContainer bind:dragItem={root} classes={["root-container"]} {layoutState} />
</div>
{/if}
{#if showMenu}
<Menu {...menuPos} on:click={closeMenu} on:clickoutside={closeMenu}>
@@ -188,7 +179,7 @@
{/if}
<style lang="scss">
#comfy-ui-panes {
#comfy-workflow-view {
width: 100%;
height: 100%;
overflow: auto;

View File

@@ -0,0 +1,555 @@
<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes';
import { PlusSquareIcon as PlusSquare } from 'svelte-feather-icons';
import { Button } from "@gradio/button";
import { BlockTitle } from "@gradio/atoms";
import ComfyWorkflowView from "./ComfyWorkflowView.svelte";
import { Checkbox, TextBox } from "@gradio/form"
import ComfyQueue from "./ComfyQueue.svelte";
import ComfyUnlockUIButton from "./ComfyUnlockUIButton.svelte";
import ComfyGraphView from "./ComfyGraphView.svelte";
import { get, writable, type Writable } from "svelte/store";
import ComfyProperties from "./ComfyProperties.svelte";
import uiState from "$lib/stores/uiState";
import workflowState, { ComfyWorkflow } from "$lib/stores/workflowState";
import selectionState from "$lib/stores/selectionState";
import type ComfyApp from './ComfyApp';
import { onMount } from "svelte";
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import { fade } from 'svelte/transition';
import { cubicIn } from 'svelte/easing';
export let app: ComfyApp;
export let uiTheme: string = "gradio-dark" // TODO config
let workflow: ComfyWorkflow | null = null;
let openedWorkflows = []
let containerElem: HTMLDivElement;
let resizeTimeout: NodeJS.Timeout | null;
let alreadySetup: Writable<boolean> = writable(false);
let fileInput: HTMLInputElement = undefined;
let loading = true;
let appSetupPromise: Promise<void> = null;
$: workflow = $workflowState.activeWorkflow;
$: openedWorkflows = $workflowState.openedWorkflows.map(w => { return { id: w.id } })
onMount(async () => {
appSetupPromise = app.setup().then(() => {
loading = false;
refreshView();
}
);
refreshView();
})
$: if (app) {
alreadySetup = app.alreadySetup;
}
async function doRefreshCombos() {
await app.refreshComboInNodes(undefined, true)
}
function refreshView(event?: Event) {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(app.resizeCanvas.bind(app), 250);
}
$: if (app?.lCanvas) {
app.lCanvas.allow_dragnodes = $uiState.uiUnlocked;
app.lCanvas.allow_interaction = $uiState.uiUnlocked;
if (!$uiState.uiUnlocked) {
app.lCanvas.deselectAllNodes();
$selectionState.currentSelectionNodes = []
}
}
$: if ($uiState.uiEditMode)
$selectionState.currentSelection = []
let graphSize = 0;
let graphTransitioning = false;
$: if (containerElem) {
const canvas = containerElem.querySelector<HTMLDivElement>("#graph-canvas")
if (canvas) {
const paneNode = canvas.closest(".splitpanes__pane")
if (paneNode) {
(paneNode as HTMLElement).ontransitionstart = () => {
graphTransitioning = true
}
(paneNode as HTMLElement).ontransitionend = () => {
graphTransitioning = false
app.resizeCanvas()
}
}
}
}
function queuePrompt() {
app.runDefaultQueueAction()
}
function toggleGraph() {
if (graphSize == 0) {
graphSize = 50;
app.resizeCanvas();
}
else {
graphSize = 0;
}
}
let propsSidebarSize = 0;
function toggleProps() {
if (propsSidebarSize == 0) {
propsSidebarSize = 15;
app.resizeCanvas();
}
else {
propsSidebarSize = 0;
}
}
let queueSidebarSize = 20;
function toggleQueue() {
if (queueSidebarSize == 0) {
queueSidebarSize = 20;
app.resizeCanvas();
}
else {
queueSidebarSize = 0;
}
}
function doSave(): void {
app.querySave()
}
function doLoad(): void {
if (!fileInput)
return;
fileInput.click();
}
function loadWorkflow(): void {
app.handleFile(fileInput.files[0]);
fileInput.files = null;
}
function doSaveLocal(): void {
app.saveStateToLocalStorage();
}
async function doLoadDefault() {
var confirmed = confirm("Would you like to load the default workflow in a new tab?");
if (confirmed) {
await app.initDefaultWorkflow();
}
}
function createNewWorkflow() {
app.createNewWorkflow();
}
function closeWorkflow(event: Event, workflow: ComfyWorkflow) {
event.preventDefault();
event.stopImmediatePropagation()
if (workflow.isModified) {
if (!confirm("This workflow has unsaved changes. Are you sure you want to close it?"))
return;
}
app.closeWorkflow(workflow.id);
}
function handleConsider(evt: any) {
console.warn(openedWorkflows.length, openedWorkflows, evt.detail.items.length, evt.detail.items)
openedWorkflows = evt.detail.items;
// openedWorkflows = evt.detail.items.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID);
// workflowState.update(s => {
// s.openedWorkflows = openedWorkflows.map(w => workflowState.getWorkflow(w.id));
// return s;
// })
};
function handleFinalize(evt: any) {
openedWorkflows = evt.detail.items;
workflowState.update(s => {
s.openedWorkflows = openedWorkflows.filter(w => w.id !== SHADOW_PLACEHOLDER_ITEM_ID).map(w => workflowState.getWorkflow(w.id));
return s;
})
};
</script>
<div id="comfy-content" bind:this={containerElem} class:loading>
<Splitpanes theme="comfy" on:resize={refreshView}>
<Pane bind:size={propsSidebarSize}>
<div class="sidebar-wrapper pane-wrapper">
<ComfyProperties workflow={$workflowState.activeWorkflow} />
</div>
</Pane>
<Pane>
<Splitpanes theme="comfy" on:resize={refreshView} horizontal="{true}">
<Pane>
{#if $workflowState.activeWorkflow != null}
<ComfyWorkflowView {app} workflow={$workflowState.activeWorkflow} />
{:else}
<span>No workflow loaded</span>
{/if}
</Pane>
<Pane bind:size={graphSize}>
<ComfyGraphView {app} transitioning={graphTransitioning} />
</Pane>
</Splitpanes>
</Pane>
<Pane bind:size={queueSidebarSize}>
<div class="sidebar-wrapper pane-wrapper">
<ComfyQueue {app} />
</div>
</Pane>
</Splitpanes>
<div id="workflow-tabs">
<div class="workflow-tab-items"
use:dndzone="{{
items: openedWorkflows,
flipDurationMs: 200,
type: "workflow-tab",
morphDisabled: true,
dropFromOthersDisabled: true,
dropTargetStyle: {outline: "none"},
}}"
on:consider={handleConsider}
on:finalize={handleFinalize}>
{#each openedWorkflows.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item(item.id)}
{@const workflow = workflowState.getWorkflow(item.id)}
<button class="workflow-tab"
class:selected={item.id === $workflowState.activeWorkflowID}
on:click={() => app.setActiveWorkflow(item.id)}>
<span class="workflow-tab-title">
{workflow.attrs.title}
{#if workflow.isModified}
*
{/if}
</span>
<button class="workflow-close-button"
on:click={(e) => closeWorkflow(e, workflow)}>
</button>
{#if workflow[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='drag-item-shadow'/>
{/if}
</button>
{/each}
</div>
<button class="workflow-add-new-button"
on:click={createNewWorkflow}>
<PlusSquare size="100%" strokeWidth={1.5} />
</button>
</div>
<div id="bottombar">
<div class="bottombar-content">
<div class="left">
{#if workflow != null && workflow.attrs.queuePromptButtonName != ""}
<Button variant="primary" disabled={!$alreadySetup} on:click={queuePrompt}>
{workflow.attrs.queuePromptButtonName}
</Button>
{/if}
<Button variant="secondary" disabled={!$alreadySetup} on:click={toggleGraph}>
Toggle Graph
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={toggleProps}>
Toggle Props
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={toggleQueue}>
Toggle Queue
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doSave}>
Save
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doSaveLocal}>
Save Local
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doLoad}>
Load
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doLoadDefault}>
Load Default
</Button>
<Button variant="secondary" disabled={!$alreadySetup} on:click={doRefreshCombos}>
🔄
</Button>
<!-- <Checkbox label="Lock Nodes" bind:value={$uiState.nodesLocked}/>
<Checkbox label="Disable Interaction" bind:value={$uiState.graphLocked}/> -->
<span style="display: inline-flex !important">
<Checkbox label="Auto-Add UI" bind:value={$uiState.autoAddUI}/>
</span>
<span class="label" for="ui-edit-mode">
<BlockTitle>UI Edit mode</BlockTitle>
<select id="ui-edit-mode" name="ui-edit-mode" bind:value={$uiState.uiEditMode}>
<option value="widgets">Widgets</option>
</select>
</span>
<span class="label" for="ui-theme">
<BlockTitle>Theme</BlockTitle>
<select id="ui-theme" name="ui-theme" bind:value={uiTheme}>
<option value="gradio-dark">Gradio Dark</option>
<option value="gradio-light">Gradio Light</option>
<option value="anapnoe">Anapnoe</option>
</select>
</span>
</div>
<div class="right">
<ComfyUnlockUIButton bind:toggled={$uiState.uiUnlocked} />
</div>
</div>
</div>
</div>
<input bind:this={fileInput} id="comfy-file-input" type="file" accept=".json" on:change={loadWorkflow} />
{#if appSetupPromise}
{#await appSetupPromise}
<div class="comfy-app-loading">
<span>Loading...</span>
</div>
{:catch error}
<div class="comfy-loading-error">
<div>
Error loading app
</div>
<div>{error}</div>
{#if error.stack}
{@const lines = error.stack.split("\n")}
{#each lines as line}
<div style:font-size="16px">{line}</div>
{/each}
{/if}
</div>
{/await}
{/if}
<style lang="scss">
$top-bar-height: 3.5rem;
$workflow-tabs-height: 2.5rem;
$bottom-bar-height: 5rem;
.comfy-app-loading, .comfy-loading-error {
font-size: 40px;
color: var(--body-text-color);
justify-content: center;
margin: auto;
width: 100%;
height: 100%;
text-align: center;
flex-direction: column;
display: flex;
position: absolute;
z-index: 100000000;
background: rgba(0, 0, 0, 0.5);
pointer-events: none;
user-select: none;
top: 0px;
}
.comfy-app-loading > span {
display: flex;
flex-direction: row;
justify-content: center;
}
#comfy-content {
grid-area: content;
height: calc(100vh - $bottom-bar-height - $workflow-tabs-height);
&.loading {
pointer-events: none;
user-select: none;
}
}
#workflow-tabs, .workflow-tab-items {
display: flex;
overflow-x: auto;
}
#workflow-tabs {
background: var(--neutral-800);
padding-right: 1em;
height: 3rem;
.workflow-tab {
background: var(--neutral-800);
color: var(--neutral-500);
padding: 0.5rem 1rem;
border-top: 3px solid var(--neutral-600);
border-left: 1px solid var(--neutral-600);
height: 100%;
display: flex;
flex-direction: row;
justify-content: center;
text-align: center;
gap: var(--size-4);
cursor: pointer !important;
> span {
margin: auto;
}
&:hover:not(:has(.workflow-close-button:hover)) {
background: var(--neutral-700);
color: var(--neutral-300);
}
&.selected {
background: var(--neutral-700);
color: var(--neutral-300);
border-top-color: var(--primary-500);
font-weight: bold;
}
> .workflow-close-button {
display:block;
width: 1.2rem;
height: 1.2rem;
font-size: 13px;
margin: auto;
border-radius: 50%;
opacity: 50%;
background: var(--neutral-500);
color: var(--neutral-300);
&:hover {
opacity: 100%;
color: var(--neutral-100);
}
}
}
.workflow-add-new-button {
background: var(--neutral-700);
color: var(--neutral-400);
opacity: 50%;
padding: 0.5rem;
border-top: 3px solid var(--neutral-600);
aspect-ratio: 1/1;
height: 100%;
display: flex;
flex-direction: row;
justify-content: center;
gap: var(--size-4);
&:hover {
filter: brightness(100%);
opacity: 100%;
border-top-color: var(--neutral-600);
}
}
}
#bottombar {
background: var(--neutral-900);
border-left: 2px solid var(--neutral-700);
display: flex;
width: 100%;
gap: var(--layout-gap);
overflow-x: hidden;
flex-wrap: nowrap;
height: $bottom-bar-height;
> .bottombar-content {
display: flex;
flex-wrap: nowrap;
width: 100%;
overflow-x: auto;
margin: auto 0;
padding-left: 1rem;
> .left {
flex-shrink: 0;
}
> .right {
margin-left: auto;
margin-right: 1rem;
padding-left: 1rem;
}
}
}
.sidebar-wrapper {
width: 100%;
height: 100%;
}
:global(html, body) {
width: 100%;
height: 100%;
margin: 0px;
font-family: Arial;
}
:global(.splitpanes.comfy>.splitpanes__splitter) {
background: var(--comfy-splitpanes-background-fill);
&:hover:not([disabled]) {
background: var(--comfy-splitpanes-background-fill-hover);
}
&:active:not([disabled]) {
background: var(--comfy-splitpanes-background-fill-active);
}
}
$splitter-size: 1rem;
:global(.splitpanes.comfy.splitpanes--horizontal>.splitpanes__splitter) {
min-height: $splitter-size;
cursor: row-resize;
}
:global(.splitpanes.comfy.splitpanes--vertical>.splitpanes__splitter) {
min-width: $splitter-size;
cursor: col-resize;
}
:global(.splitpanes.comfy) {
max-height: calc(100vh - $bottom-bar-height);
max-width: 100vw;
}
:global(.splitpanes__pane) {
box-shadow: 0 0 3px rgba(0, 0, 0, .2) inset;
justify-content: center;
align-items: center;
display: flex;
position: relative;
}
label.label > :global(span) {
top: 20%;
}
span.left {
right: 0px;
}
#comfy-file-input {
display: none;
}
.drag-item-shadow {
visibility: visible;
border: 1px dashed grey;
background: lightblue;
opacity: 0.5;
margin: 0;
}
</style>

View File

@@ -6,10 +6,11 @@
import TabsContainer from "./TabsContainer.svelte"
// notice - fade in works fine but don't add svelte's fade-out (known issue)
import { type ContainerLayout } from "$lib/stores/layoutState";
import { type ContainerLayout, type WritableLayoutStateStore } from "$lib/stores/layoutStates";
import type { Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils";
export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null;
export let zIndex: number = 0;
export let classes: string[] = [];
@@ -31,11 +32,11 @@
{#key $attrsChanged}
{#if edit || !isHidden(container)}
{#if container.attrs.variant === "tabs"}
<TabsContainer {container} {zIndex} {classes} {showHandles} {edit} {dragDisabled} {isMobile} />
<TabsContainer {layoutState} {container} {zIndex} {classes} {showHandles} {edit} {dragDisabled} {isMobile} />
{:else if container.attrs.variant === "accordion"}
<AccordionContainer {container} {zIndex} {classes} {showHandles} {edit} {dragDisabled} {isMobile} />
<AccordionContainer {layoutState} {container} {zIndex} {classes} {showHandles} {edit} {dragDisabled} {isMobile} />
{:else}
<BlockContainer {container} {zIndex} {classes} {showHandles} {edit} {dragDisabled} {isMobile} />
<BlockContainer {layoutState} {container} {zIndex} {classes} {showHandles} {edit} {dragDisabled} {isMobile} />
{/if}
{/if}
{/key}

View File

@@ -162,6 +162,10 @@
position: relative;
flex: 1 1 0%;
max-width: 30vw;
> :global(.block) {
height: 100%;
}
}
.copy-button {

View File

@@ -0,0 +1,154 @@
<script context="module">
export const TABS = {};
</script>
<script lang="ts">
import { setContext, createEventDispatcher } from "svelte";
import { writable } from "svelte/store";
import type { SelectData } from "@gradio/utils";
import { SvelteComponentDev } from "svelte/internal";
interface Tab {
name: string;
id: object;
icon: typeof SvelteComponentDev | null;
}
export let visible: boolean = true;
export let elem_id: string = "id";
export let elem_classes: Array<string> = [];
export let selected: number | string | object;
let tabs: Array<Tab> = [];
const selected_tab = writable<false | object | number | string>(false);
const selected_tab_index = writable<number>(0);
const dispatch = createEventDispatcher<{
change: undefined;
select: SelectData;
}>();
setContext(TABS, {
register_tab: (tab: Tab) => {
tabs.push({ name: tab.name, id: tab.id, icon: tab.icon });
selected_tab.update((current) => current ?? tab.id);
tabs = tabs;
return tabs.length - 1;
},
unregister_tab: (tab: Tab) => {
const i = tabs.findIndex((t) => t.id === tab.id);
tabs.splice(i, 1);
selected_tab.update((current) =>
current === tab.id ? tabs[i]?.id || tabs[tabs.length - 1]?.id : current
);
},
selected_tab,
selected_tab_index
});
function change_tab(id: object | string | number) {
selected = id;
$selected_tab = id;
$selected_tab_index = tabs.findIndex((t) => t.id === id);
dispatch("change");
}
$: selected !== null && change_tab(selected);
</script>
<div class="sidebar {elem_classes.join(' ')}" class:hide={!visible} id={elem_id}>
<div class="sidebar-nav scroll-hide">
{#each tabs as t, i (t.id)}
{#if t.id === $selected_tab}
<button class="selected">
{#if t.icon !== null}
<svelte:component this={t.icon} size="100%" strokeWidth={1.5} />
{:else}
{t.name}
{/if}
</button>
{:else}
<button
on:click={() => {
change_tab(t.id);
dispatch("select", { value: t.name, index: i });
}}
>
{#if t.icon !== null}
<svelte:component this={t.icon} size="100%" strokeWidth={1.5} />
{:else}
{t.name}
{/if}
</button>
{/if}
{/each}
<div class="sidebar-rest"/>
</div>
<slot />
</div>
<style lang="scss">
.sidebar {
background: var(--neutral-900);
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
float: left;
top: 0px;
padding: 0;
}
.hide {
display: none;
}
.sidebar-nav, .sidebar-rest {
display: flex;
position: relative;
flex-wrap: wrap;
white-space: nowrap;
width: 4rem;
height: 100%;
background: var(--neutral-800);
> button {
width: 4rem;
height: 4rem;
padding: 0.5rem;
color: var(--neutral-600);
border-right: 3px solid transparent;
display: flex;
flex-direction: column;
justify-content: center;
&:hover {
background: var(--neutral-700);
color: var(--neutral-500);
}
&.selected {
background: var(--neutral-700);
color: var(--neutral-300);
border-right-color: var(--primary-500);
}
}
}
.sidebar-rest {
height: 100%;
}
.bar {
display: block;
position: absolute;
bottom: -2px;
left: 0;
z-index: 999;
background: var(--background-fill-primary);
width: 100%;
height: 2px;
content: "";
}
</style>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { getContext, onMount, createEventDispatcher, tick } from "svelte";
import { TABS } from "./Sidebar.svelte";
import Column from "$lib/components/gradio/app/Column.svelte"
import type { SelectData } from "@gradio/utils";
import { SvelteComponentDev } from "svelte/internal";
export let elem_id: string = "";
export let elem_classes: Array<string> = [];
export let name: string;
export let id: string | number | object = {};
export let icon: typeof SvelteComponentDev | null = null;
const dispatch = createEventDispatcher<{ select: SelectData }>();
const { register_tab, unregister_tab, selected_tab, selected_tab_index } =
getContext(TABS) as any;
let tab_index = register_tab({ name, id, icon });
onMount(() => {
return () => unregister_tab({ name, id, icon });
});
$: $selected_tab_index === tab_index &&
tick().then(() => dispatch("select", { value: name, index: tab_index }));
</script>
<div
id={elem_id}
class="sidebar-item {elem_classes.join(' ')}"
style:display={$selected_tab === id ? "block" : "none"}
>
<div style:height="100%">
<slot />
</div>
</div>
<style>
div {
position: relative;
}
.sidebar-item {
width: calc(100% - 4rem);
height: 100%;
}
</style>

View File

@@ -11,11 +11,12 @@
// notice - fade in works fine but don't add svelte's fade-out (known issue)
import {cubicIn} from 'svelte/easing';
import { flip } from 'svelte/animate';
import layoutState, { type ContainerLayout, type WidgetLayout, type IDragItem } from "$lib/stores/layoutState";
import { type ContainerLayout, type WidgetLayout, type IDragItem, type WritableLayoutStateStore } from "$lib/stores/layoutStates";
import { startDrag, stopDrag } from "$lib/utils"
import type { Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils";
export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null;
export let zIndex: number = 0;
export let classes: string[] = [];
@@ -24,19 +25,17 @@
export let dragDisabled: boolean = false;
export let isMobile: boolean = false;
let attrsChanged: Writable<boolean> | null = null;
// let attrsChanged: Writable<number> = writable(0);
let children: IDragItem[] = [];
const flipDurationMs = 100;
let selectedIndex: number = 0;
$: if (container) {
children = $layoutState.allItems[container.id].children;
attrsChanged = container.attrsChanged
// attrsChanged = container.attrsChanged
}
else {
children = [];
attrsChanged = null
// attrsChanged = writable(0)
}
function handleConsider(evt: any) {
@@ -66,6 +65,14 @@
function handleSelect() {
navigator.vibrate(20)
}
function _startDrag(e: MouseEvent | TouchEvent) {
startDrag(e, layoutState)
}
function _stopDrag(e: MouseEvent | TouchEvent) {
stopDrag(e, layoutState)
}
</script>
{#if container}
@@ -103,7 +110,7 @@
<label for={String(item.id)}>
<BlockTitle><strong>Tab {i+1}:</strong> {tabName}</BlockTitle>
</label>
<WidgetContainer dragItem={item} zIndex={zIndex+1} {isMobile} />
<WidgetContainer {layoutState} dragItem={item} zIndex={zIndex+1} {isMobile} />
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='drag-item-shadow'/>
{/if}
@@ -112,18 +119,26 @@
{/each}
</div>
{#if isHidden(container) && edit}
<div class="handle handle-hidden" style="z-index: {zIndex+100}" class:hidden={!edit} />
<div class="handle handle-hidden"
style:z-index={zIndex+100}
class:hidden={!edit} />
{/if}
{#if showHandles}
<div class="handle handle-container" style="z-index: {zIndex+100}" data-drag-item-id={container.id} on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
<div class="handle handle-container"
style:z-index={zIndex+100}
data-drag-item-id={container.id}
on:mousedown={_startDrag}
on:touchstart={_startDrag}
on:mouseup={_stopDrag}
on:touchend={_stopDrag}/>
{/if}
</Block>
{:else}
<Tabs elem_classes={["gradio-tabs"]} on:select={handleSelect}>
{#each children.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item, i(item.id)}
{@const tabName = getTabName(container, i)}
<TabItem name={tabName}>
<WidgetContainer dragItem={item} zIndex={zIndex+1} {isMobile} />
<TabItem id={tabName} name={tabName}>
<WidgetContainer {layoutState} dragItem={item} zIndex={zIndex+1} {isMobile} />
</TabItem>
{/each}
</Tabs>

View File

@@ -2,21 +2,22 @@
import queueState from "$lib/stores/queueState";
import uiState from "$lib/stores/uiState";
import layoutState, { type ContainerLayout, type WidgetLayout, type IDragItem } from "$lib/stores/layoutState";
import { type ContainerLayout, type WidgetLayout, type IDragItem, type WritableLayoutStateStore } from "$lib/stores/layoutStates";
import selectionState from "$lib/stores/selectionState";
import { startDrag, stopDrag } from "$lib/utils"
import Container from "./Container.svelte"
import { type Writable } from "svelte/store"
import { writable, type Writable } from "svelte/store"
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import { isHidden } from "$lib/widgets/utils";
export let layoutState: WritableLayoutStateStore;
export let dragItem: IDragItem | null = null;
export let zIndex: number = 0;
export let classes: string[] = [];
export let isMobile: boolean = false;
let container: ContainerLayout | null = null;
let attrsChanged: Writable<boolean> | null = null;
let propsChanged: Writable<number> | null = null;
let attrsChanged: Writable<number> = writable(0);
let propsChanged: Writable<number> = writable(0);
let widget: WidgetLayout | null = null;
let showHandles: boolean = false;
@@ -24,8 +25,8 @@
dragItem = null;
container = null;
widget = null;
attrsChanged = null;
propsChanged = null;
attrsChanged = writable(0);
propsChanged = writable(0);
}
else if (dragItem.type === "container") {
container = dragItem as ContainerLayout;
@@ -40,7 +41,7 @@
if (widget.node && "propsChanged" in widget.node)
propsChanged = (widget.node as ComfyWidgetNode).propsChanged
else
propsChanged = null;
propsChanged = writable(0);
}
$: showHandles = $uiState.uiUnlocked
@@ -57,12 +58,20 @@
const title = widget.node.type.replace("/", "-").replace(".", "-")
return `widget--${title}`
}
function _startDrag(e: MouseEvent | TouchEvent) {
startDrag(e, layoutState)
}
function _stopDrag(e: MouseEvent | TouchEvent) {
stopDrag(e, layoutState)
}
</script>
{#if container}
{#key $attrsChanged}
<Container {container} {classes} {zIndex} {showHandles} {isMobile} />
<Container {layoutState} {container} {classes} {zIndex} {showHandles} {isMobile} />
{/key}
{:else if widget && widget.node}
{@const edit = $uiState.uiUnlocked && $uiState.uiEditMode === "widgets"}
@@ -78,13 +87,19 @@
class:is-executing={$queueState.runningNodeID && $queueState.runningNodeID == widget.node.id}
class:hidden={hidden}
>
<svelte:component this={widget.node.svelteComponentType} {widget} {isMobile} />
<svelte:component this={widget.node.svelteComponentType} {layoutState} {widget} {isMobile} />
</div>
{#if hidden && edit}
<div class="handle handle-hidden" class:hidden={!edit} />
{/if}
{#if showHandles || hovered}
<div class="handle handle-widget" class:hovered data-drag-item-id={widget.id} on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
<div class="handle handle-widget"
class:hovered
data-drag-item-id={widget.id}
on:mousedown={_startDrag}
on:touchstart={_startDrag}
on:mouseup={_stopDrag}
on:touchend={_stopDrag}/>
{/if}
{/key}
{/key}

View File

@@ -1,8 +1,13 @@
import type SerializedAppState from "./ComfyApp"
import type { SerializedAppState } from "./components/ComfyApp"
const blankGraph: SerializedAppState = {
createdBy: "ComfyBox",
version: 1,
attrs: {
title: "New Workflow",
queuePromptButtonName: "Queue Prompt",
queuePromptButtonRunWorkflow: true
},
workflow: {
last_node_id: 0,
last_link_id: 0,
@@ -13,7 +18,14 @@ const blankGraph: SerializedAppState = {
extra: {},
version: 0
},
panes: {}
layout: {
root: null,
allItems: {},
},
canvas: {
offset: [0, 0],
scale: 1
}
}
export { blankGraph }

View File

@@ -1,7 +1,8 @@
import ComfyGraph from '$lib/ComfyGraph';
import { LGraphCanvas, LiteGraph, Subgraph } from '@litegraph-ts/core';
import layoutState from './stores/layoutState';
import layoutStates from './stores/layoutStates';
import { get } from 'svelte/store';
import workflowState from './stores/workflowState';
export function configureLitegraph(isMobile: boolean = false) {
LiteGraph.catch_exceptions = false;
@@ -28,5 +29,7 @@ export function configureLitegraph(isMobile: boolean = false) {
(window as any).LiteGraph = LiteGraph;
(window as any).LGraphCanvas = LGraphCanvas;
(window as any).layoutState = get(layoutState)
(window as any).layoutStates = layoutStates;
(window as any).workflowState = workflowState;
(window as any).svelteGet = get;
}

View File

@@ -1,6 +1,6 @@
import type { SerializedPrompt } from "$lib/components/ComfyApp";
import notify from "$lib/notify";
import layoutState, { type DragItemID } from "$lib/stores/layoutState";
import { type DragItemID } from "$lib/stores/layoutStates";
import queueState from "$lib/stores/queueState";
import { BuiltInSlotType, LiteGraph, NodeMode, type ITextWidget, type IToggleWidget, type SerializedLGraphNode, type SlotLayout, type PropertyLayout } from "@litegraph-ts/core";
import { get } from "svelte/store";
@@ -8,7 +8,7 @@ import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode"
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import type { NotifyOptions } from "$lib/notify";
import type { FileData as GradioFileData } from "@gradio/upload";
import { type ComfyExecutionResult, type ComfyImageLocation, convertComfyOutputToGradio, type ComfyUploadImageAPIResponse, parseWhateverIntoComfyImageLocations } from "$lib/utils";
import { type SerializedPromptOutput, type ComfyImageLocation, convertComfyOutputToGradio, type ComfyUploadImageAPIResponse, parseWhateverIntoComfyImageLocations } from "$lib/utils";
export class ComfyQueueEvents extends ComfyGraphNode {
static slotLayout: SlotLayout = {
@@ -63,7 +63,7 @@ LiteGraph.registerNodeType({
})
export interface ComfyStoreImagesActionProperties extends ComfyGraphNodeProperties {
images: ComfyExecutionResult | null
images: SerializedPromptOutput | null
}
export class ComfyStoreImagesAction extends ComfyGraphNode {
@@ -90,7 +90,7 @@ export class ComfyStoreImagesAction extends ComfyGraphNode {
if (action !== "store" || !param || !("images" in param))
return;
this.setProperty("images", param as ComfyExecutionResult)
this.setProperty("images", param as SerializedPromptOutput)
this.setOutputData(0, this.properties.images)
}
}
@@ -223,7 +223,7 @@ export class ComfyNotifyAction extends ComfyGraphNode {
// native notifications.
if (param != null && typeof param === "object") {
if ("images" in param) {
const output = param as ComfyExecutionResult;
const output = param as SerializedPromptOutput;
const converted = convertComfyOutputToGradio(output);
if (converted.length > 0)
options.imageUrl = converted[0].data;
@@ -389,7 +389,7 @@ export class ComfySetNodeModeAction extends ComfyGraphNode {
}
}
for (const entry of Object.values(get(layoutState).allItems)) {
for (const entry of Object.values(get(this.layoutState).allItems)) {
if (entry.dragItem.type === "container") {
const container = entry.dragItem;
const hasTag = tags.some(t => container.attrs.tags.indexOf(t) != -1);
@@ -488,7 +488,7 @@ export class ComfySetNodeModeAdvancedAction extends ComfyGraphNode {
}
}
for (const entry of Object.values(get(layoutState).allItems)) {
for (const entry of Object.values(get(this.layoutState).allItems)) {
if (entry.dragItem.type === "container") {
const container = entry.dragItem;
const hasTag = container.attrs.tags.indexOf(action.tag) != -1;
@@ -532,7 +532,7 @@ export class ComfySetNodeModeAdvancedAction extends ComfyGraphNode {
this.graph.getNodeByIdRecursive(nodeId).changeMode(newMode);
}
const layout = get(layoutState);
const layout = get(this.layoutState);
for (const [dragItemID, isHidden] of Object.entries(widgetChanges)) {
const container = layout.allItems[dragItemID].dragItem
container.attrs.hidden = isHidden;

View File

@@ -6,7 +6,7 @@ import { BuiltInSlotShape, BuiltInSlotType, type SerializedLGraphNode } from "@l
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import type { ComfyInputConfig } from "$lib/IComfyInputSlot";
import { iterateNodeDefOutputs, type ComfyNodeDef, iterateNodeDefInputs } from "$lib/ComfyNodeDef";
import type { ComfyExecutionResult } from "$lib/utils";
import type { SerializedPromptOutput } from "$lib/utils";
/*
* Base class for any node with configuration sent by the backend.
@@ -111,7 +111,7 @@ export class ComfyBackendNode extends ComfyGraphNode {
}
}
override onExecuted(outputData: ComfyExecutionResult) {
override onExecuted(outputData: SerializedPromptOutput) {
console.warn("onExecuted outputs", outputData)
this.triggerSlot(0, outputData)
}

View File

@@ -1,13 +1,9 @@
import layoutState from "$lib/stores/layoutState"
import layoutStates from "$lib/stores/layoutStates"
import { BuiltInSlotType, LGraphNode, LiteGraph, type ITextWidget, type OptionalSlots, type PropertyLayout, type SlotLayout, type Vector2 } from "@litegraph-ts/core"
import { get } from "svelte/store"
import ComfyGraphNode from "./ComfyGraphNode"
export interface ComfyConfigureQueuePromptButtonProperties extends Record<string, any> {
}
export default class ComfyConfigureQueuePromptButton extends LGraphNode {
override properties: ComfyConfigureQueuePromptButtonProperties = {
}
export default class ComfyConfigureQueuePromptButton extends ComfyGraphNode {
static slotLayout: SlotLayout = {
inputs: [
{ name: "config", type: BuiltInSlotType.ACTION },
@@ -28,13 +24,15 @@ export default class ComfyConfigureQueuePromptButton extends LGraphNode {
override onAction(action: any, param: any, options: { action_call?: string }) {
if (action === "config" && param != null) {
layoutState.update(state => {
if (this.layoutState == null) {
console.error(this, this.getRootGraph(), Object.keys(get(layoutStates).all))
throw new Error(`Could not find layout attached to this node! ${this.id}`)
}
if (typeof param === "string")
state.attrs.queuePromptButtonName = param || ""
this.workflow.setAttribute("queuePromptButtonName", param || "")
else if (typeof param === "object" && "buttonName" in param)
state.attrs.queuePromptButtonName = param.buttonName || ""
return state
})
this.workflow.setAttribute("queuePromptButtonName", param.buttonName || "")
}
}
}

View File

@@ -3,11 +3,14 @@ import type { SerializedPrompt } from "$lib/components/ComfyApp";
import { LGraph, LGraphNode, LLink, LiteGraph, NodeMode, type INodeInputSlot, type SerializedLGraphNode, type Vector2, type INodeOutputSlot, LConnectionKind, type SlotType, LGraphCanvas, getStaticPropertyOnInstance, type PropertyLayout, type SlotLayout } from "@litegraph-ts/core";
import type { SvelteComponentDev } from "svelte/internal";
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import type { ComfyExecutionResult, ComfyImageLocation } from "$lib/utils"
import type { SerializedPromptOutput, ComfyImageLocation } from "$lib/utils"
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import uiState from "$lib/stores/uiState";
import { get } from "svelte/store";
import configState from "$lib/stores/configState";
import type { WritableLayoutStateStore } from "$lib/stores/layoutStates";
import layoutStates from "$lib/stores/layoutStates";
import workflowStateStore, { ComfyWorkflow } from "$lib/stores/workflowState";
export type DefaultWidgetSpec = {
defaultWidgetNode: new (name?: string) => ComfyWidgetNode,
@@ -48,7 +51,7 @@ export default class ComfyGraphNode extends LGraphNode {
* Triggered when the backend sends a finished output back with this node's ID.
* Valid for output nodes like SaveImage and PreviewImage.
*/
onExecuted?(output: ComfyExecutionResult): void;
onExecuted?(output: SerializedPromptOutput): void;
/*
* When a prompt is queued, this will be called on the node if it can
@@ -100,6 +103,14 @@ export default class ComfyGraphNode extends LGraphNode {
return null;
}
get layoutState(): WritableLayoutStateStore | null {
return layoutStates.getLayoutByNode(this);
}
get workflow(): ComfyWorkflow | null {
return workflowStateStore.getWorkflowByNode(this);
}
constructor(title?: string) {
super(title)
this.addProperty("tags", [], "array")

View File

@@ -1,5 +1,4 @@
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";
@@ -269,7 +268,7 @@ export default abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
}
if (options.setWidgetTitle) {
const widget = layoutState.findLayoutForNode(this.id as ComfyNodeID)
const widget = this.layoutState.findLayoutForNode(this.id as ComfyNodeID)
if (widget && input.name !== "") {
widget.attrs.title = input.name;
}
@@ -288,7 +287,7 @@ export default abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
}
notifyPropsChanged() {
const layoutEntry = layoutState.findLayoutEntryForNode(this.id as ComfyNodeID)
const layoutEntry = this.layoutState.findLayoutEntryForNode(this.id as ComfyNodeID)
if (layoutEntry && layoutEntry.parent) {
layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1)
}

View File

@@ -6,6 +6,9 @@ import { SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { ComfyNodeID } from '$lib/api';
import { v4 as uuidv4 } from "uuid";
import type { ComfyWidgetNode } from '$lib/nodes/widgets';
import type ComfyGraph from '$lib/ComfyGraph';
import type { ComfyWorkflow, WorkflowAttributes, WorkflowInstID } from './workflowState';
import workflowState from './workflowState';
function isComfyWidgetNode(node: LGraphNode): node is ComfyWidgetNode {
return "svelteComponentType" in node
@@ -29,24 +32,6 @@ type DragItemEntry = {
parent: IDragItem | null
}
/*
* Global workflow attributes
*/
export type LayoutAttributes = {
/*
* Name of the "Queue Prompt" button. Set to blank to hide the button.
*/
queuePromptButtonName: string,
/*
* If true, clicking the "Queue Prompt" button will run the default
* subgraph. Set this to false if you need special behavior before running
* any subgraphs, and instead use the `onDefaultQueueAction` event of the
* Comfy.QueueEvents node.
*/
queuePromptButtonRunWorkflow: boolean,
}
/*
* Keeps track of the tree of UI components - widgets and the containers that
* group them together.
@@ -80,16 +65,6 @@ export type LayoutState = {
* If true, the right-click context menu is open
*/
isMenuOpen: boolean,
/*
* Global workflow attributes
*/
attrs: LayoutAttributes
/*
* Increment to force Svelte to re-render the props panel
*/
refreshPropsPanel: Writable<number>
}
/**
@@ -188,7 +163,7 @@ export type AttributesSpec = {
* - "widget": inside IDragNode.attrs
* - "nodeProps": inside LGraphNode.properties
* - "nodeVars": an instance variable directly on an LGraphNode
* - "workflow": inside $layoutState.attrs
* - "workflow": inside $workflowState.activeWorkflow.attrs
*/
location: "widget" | "nodeProps" | "nodeVars" | "workflow"
@@ -573,6 +548,13 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
},
// Workflow
{
name: "title",
type: "string",
location: "workflow",
editable: true,
defaultValue: "New Workflow"
},
{
name: "queuePromptButtonName",
type: "string",
@@ -604,7 +586,7 @@ export { ALL_ATTRIBUTES };
// TODO Should be nested by category for name uniqueness?
const defaultWidgetAttributes: Attributes = {} as any
const defaultWorkflowAttributes: LayoutAttributes = {} as any
export const defaultWorkflowAttributes: WorkflowAttributes = {} as any
for (const cat of Object.values(ALL_ATTRIBUTES)) {
for (const spec of Object.values(cat.specs)) {
if (spec.defaultValue != null) {
@@ -678,6 +660,8 @@ export interface WidgetLayout extends IDragItem {
export type DragItemID = UUID;
type LayoutStateOps = {
workflow: ComfyWorkflow,
addContainer: (parent: ContainerLayout | null, attrs: Partial<Attributes>, index?: number) => ContainerLayout,
addWidget: (parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial<Attributes>, index?: number) => WidgetLayout,
findDefaultContainerForInsertion: () => ContainerLayout | null,
@@ -693,19 +677,40 @@ type LayoutStateOps = {
deserialize: (data: SerializedLayoutState, graph: LGraph) => void,
initDefaultLayout: () => void,
onStartConfigure: () => void
notifyWorkflowModified: () => void
}
export type SerializedLayoutState = {
root: DragItemID | null,
allItems: Record<DragItemID, SerializedDragEntry>,
}
export type SerializedDragEntry = {
dragItem: SerializedDragItem,
children: DragItemID[],
parent: DragItemID | null
}
export type SerializedDragItem = {
type: string,
id: DragItemID,
nodeId: UUID | null,
attrs: Attributes
}
export type WritableLayoutStateStore = Writable<LayoutState> & LayoutStateOps;
function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
if (get(layoutStates).all[workflow.id] != null) {
throw new Error(`Layout state already created! ${id}`)
}
const store: Writable<LayoutState> = writable({
root: null,
allItems: {},
allItemsByNode: {},
isMenuOpen: false,
isConfiguring: true,
refreshPropsPanel: writable(0),
attrs: {
...defaultWorkflowAttributes
}
})
function clear() {
@@ -715,10 +720,6 @@ function clear() {
allItemsByNode: {},
isMenuOpen: false,
isConfiguring: true,
refreshPropsPanel: writable(0),
attrs: {
...defaultWorkflowAttributes
}
})
}
@@ -777,6 +778,7 @@ function addContainer(parent: ContainerLayout | null, attrs: Partial<Attributes>
console.debug("[layoutState] addContainer", state)
store.set(state)
notifyWorkflowModified();
// runOnChangedForWidgetDefaults(dragItem)
return dragItem;
}
@@ -808,6 +810,7 @@ function addWidget(parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partia
console.debug("[layoutState] addWidget", state)
moveItem(dragItem, parent, index)
notifyWorkflowModified();
// runOnChangedForWidgetDefaults(dragItem)
return dragItem;
}
@@ -841,6 +844,7 @@ function removeEntry(state: LayoutState, id: DragItemID) {
delete state.allItemsByNode[widget.node.id]
}
delete state.allItems[id]
notifyWorkflowModified();
}
function nodeAdded(node: LGraphNode, options: LGraphAddNodeOptions) {
@@ -953,6 +957,7 @@ function moveItem(target: IDragItem, to: ContainerLayout, index?: number) {
state.allItems[target.id].parent = toEntry.dragItem;
console.debug("[layoutState] Move child", target, toEntry, index)
notifyWorkflowModified();
store.set(state)
}
@@ -983,6 +988,7 @@ function groupItems(dragItemIDs: DragItemID[], attrs: Partial<Attributes> = {}):
console.debug("[layoutState] Grouped", container, parent, state.allItems[container.id].children, index)
notifyWorkflowModified();
store.set(state)
return container
}
@@ -1040,41 +1046,20 @@ function initDefaultLayout() {
allItems: {},
allItemsByNode: {},
isMenuOpen: false,
isConfiguring: false,
refreshPropsPanel: writable(0),
attrs: {
...defaultWorkflowAttributes
}
isConfiguring: true,
})
const root = addContainer(null, { direction: "horizontal", title: "" });
const left = addContainer(root, { direction: "vertical", title: "" });
const right = addContainer(root, { direction: "vertical", title: "" });
const state = get(store)
state.root = root;
store.set(state)
store.update(s => {
s.root = root;
s.isConfiguring = false;
return s;
})
console.debug("[layoutState] initDefault", state)
}
export type SerializedLayoutState = {
root: DragItemID | null,
allItems: Record<DragItemID, SerializedDragEntry>,
attrs: LayoutAttributes
}
export type SerializedDragEntry = {
dragItem: SerializedDragItem,
children: DragItemID[],
parent: DragItemID | null
}
export type SerializedDragItem = {
type: string,
id: DragItemID,
nodeId: UUID | null,
attrs: Attributes
console.debug("[layoutState] initDefault")
}
function serialize(): SerializedLayoutState {
@@ -1098,7 +1083,6 @@ function serialize(): SerializedLayoutState {
return {
root: state.root?.id,
allItems,
attrs: state.attrs
}
}
@@ -1154,8 +1138,6 @@ function deserialize(data: SerializedLayoutState, graph: LGraph) {
allItemsByNode,
isMenuOpen: false,
isConfiguring: false,
refreshPropsPanel: writable(0),
attrs: { ...defaultWorkflowAttributes, ...data.attrs }
}
console.debug("[layoutState] deserialize", data, state, defaultWorkflowAttributes)
@@ -1163,7 +1145,10 @@ function deserialize(data: SerializedLayoutState, graph: LGraph) {
store.set(state)
// Ensure properties panel is updated with new state
state.refreshPropsPanel.set(get(state.refreshPropsPanel) + 1)
layoutStates.update(s => {
s.refreshPropsPanel += 1;
return s;
})
}
function onStartConfigure() {
@@ -1173,9 +1158,15 @@ function onStartConfigure() {
})
}
function notifyWorkflowModified() {
if (!get(store).isConfiguring)
workflow.notifyModified();
}
const layoutStateStore: WritableLayoutStateStore =
{
...store,
workflow,
addContainer,
addWidget,
findDefaultContainerForInsertion,
@@ -1190,6 +1181,78 @@ const layoutStateStore: WritableLayoutStateStore =
initDefaultLayout,
onStartConfigure,
serialize,
deserialize
deserialize,
notifyWorkflowModified
}
export default layoutStateStore;
layoutStates.update(s => {
s.all[workflow.id] = layoutStateStore;
return s;
})
return layoutStateStore
}
function remove(workflowID: WorkflowInstID) {
const state = get(layoutStates)
if (state.all[workflowID] == null)
throw new Error(`No workflow with ID registered! ${workflowID}`)
delete state.all[workflowID];
}
function getLayout(workflowID: WorkflowInstID): WritableLayoutStateStore | null {
return get(layoutStates).all[workflowID]
}
function getLayoutByGraph(graph: LGraph): WritableLayoutStateStore | null {
if ("workflowID" in graph && graph.workflowID != null) {
return get(layoutStates).all[(graph as ComfyGraph).workflowID]
}
return null;
}
function getLayoutByNode(node: LGraphNode): WritableLayoutStateStore | null {
const rootGraph = node.getRootGraph();
if (rootGraph == null)
return null;
return getLayoutByGraph(rootGraph);
}
export type LayoutStateStores = {
/*
* Layouts associated with opened workflows
*/
all: Record<WorkflowInstID, WritableLayoutStateStore>,
/*
* Increment to force Svelte to re-render the props panel
*/
refreshPropsPanel: number
}
export type LayoutStateStoresOps = {
create: (workflow: ComfyWorkflow) => WritableLayoutStateStore,
remove: (workflowID: WorkflowInstID) => void,
getLayout: (workflowID: WorkflowInstID) => WritableLayoutStateStore | null,
getLayoutByGraph: (graph: LGraph) => WritableLayoutStateStore | null,
getLayoutByNode: (node: LGraphNode) => WritableLayoutStateStore | null,
}
export type WritableLayoutStateStores = Writable<LayoutStateStores> & LayoutStateStoresOps;
const store = writable({
all: {},
refreshPropsPanel: 0
})
const layoutStates: WritableLayoutStateStores = {
...store,
create,
remove,
getLayout,
getLayoutByGraph,
getLayoutByNode
}
export default layoutStates;

View File

@@ -1,5 +1,5 @@
import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, ComfyNodeID, PromptID } from "$lib/api";
import type { Progress, SerializedPromptInputsAll, SerializedPromptOutputs } from "$lib/components/ComfyApp";
import type { Progress, SerializedPromptInputsAll, SerializedPromptOutputs, WorkflowInstID } from "$lib/components/ComfyApp";
import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify";
import { get, writable, type Writable } from "svelte/store";
@@ -11,12 +11,13 @@ type QueueStateOps = {
historyUpdated: (resp: ComfyAPIHistoryResponse) => void,
statusUpdated: (status: ComfyAPIStatusResponse | null) => void,
executionStart: (promptID: PromptID) => void,
executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => void,
executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => QueueEntry | null;
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: ComfyNodeID, output: ComfyExecutionResult) => void
getQueueEntry: (promptID: PromptID) => QueueEntry | null;
afterQueued: (workflowID: WorkflowInstID, promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void
onExecuted: (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => QueueEntry | null
}
/*
@@ -36,6 +37,8 @@ export type QueueEntry = {
/*** Data not sent by ComfyUI's API, lost on page refresh ***/
/* Workflow tab that sent the prompt. */
workflowID?: WorkflowInstID,
/* Prompt outputs, collected while the prompt is still executing */
outputs: SerializedPromptOutputs,
/* Nodes of the workflow that have finished running so far. */
@@ -150,6 +153,21 @@ function statusUpdated(status: ComfyAPIStatusResponse | null) {
})
}
function getQueueEntry(promptID: PromptID): QueueEntry | null {
const state = get(store);
let found = get(state.queuePending).find(e => e.promptID === promptID)
if (found != null) return found;
found = get(state.queueRunning).find(e => e.promptID === promptID)
if (found != null) return found;
let foundCompleted = get(state.queueCompleted).find(e => e.entry.promptID === promptID)
if (foundCompleted != null) return foundCompleted.entry;
return null;
}
function findEntryInPending(promptID: PromptID): [number, QueueEntry | null, Writable<QueueEntry[]> | null] {
const state = get(store);
let index = get(state.queuePending).findIndex(e => e.promptID === promptID)
@@ -180,8 +198,10 @@ function moveToCompleted(index: number, queue: Writable<QueueEntry[]>, status: Q
store.set(state)
}
function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null) {
function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null): QueueEntry | null {
console.debug("[queueState] executingUpdated", promptID, runningNodeID)
let entry_ = null;
store.update((s) => {
s.progress = null;
@@ -214,8 +234,11 @@ function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null)
s.progress = null;
s.runningNodeID = null;
}
entry_ = entry;
return s
})
return entry_;
}
function executionCached(promptID: PromptID, nodes: ComfyNodeID[]) {
@@ -283,16 +306,18 @@ function executionStart(promptID: PromptID) {
})
}
function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) {
function afterQueued(workflowID: WorkflowInstID, promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) {
console.debug("[queueState] afterQueued", promptID, Object.keys(prompt))
store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID);
if (entry == null) {
const entry = createNewQueueEntry(promptID, number, prompt, extraData);
entry.workflowID = workflowID;
s.queuePending.update(qp => { qp.push(entry); return qp })
console.debug("[queueState] ADD PROMPT", promptID)
}
else {
entry.workflowID = workflowID;
entry.number = number;
entry.prompt = prompt
entry.extraData = extraData
@@ -304,19 +329,22 @@ function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromp
})
}
function onExecuted(promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) {
console.debug("[queueState] onExecuted", promptID, nodeID, output)
function onExecuted(promptID: PromptID, nodeID: ComfyNodeID, outputs: ComfyExecutionResult): QueueEntry | null {
console.debug("[queueState] onExecuted", promptID, nodeID, outputs)
let entry_ = null;
store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID)
if (entry != null) {
entry.outputs[nodeID] = output;
entry.outputs[nodeID] = outputs;
queue.set(get(queue))
}
else {
console.error("[queueState] Could not find in pending! (onExecuted)", promptID)
}
entry_ = entry;
return s
})
return entry_;
}
const queueStateStore: WritableQueueStateStore =
@@ -331,6 +359,7 @@ const queueStateStore: WritableQueueStateStore =
executionCached,
executionError,
afterQueued,
getQueueEntry,
onExecuted
}
export default queueStateStore;

View File

@@ -1,6 +1,6 @@
import { writable } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store';
import type { DragItemID, IDragItem } from './layoutState';
import type { DragItemID, IDragItem } from './layoutStates';
import type { LGraphNode, NodeID } from '@litegraph-ts/core';
export type SelectionState = {

View File

@@ -0,0 +1,361 @@
import type { SerializedGraphCanvasState } from '$lib/ComfyGraphCanvas';
import { clamp, LGraphNode, type LGraphCanvas, type NodeID, type SerializedLGraph, type UUID, LGraph } 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';
import ComfyGraph from '$lib/ComfyGraph';
import layoutStates from './layoutStates';
import { v4 as uuidv4 } from "uuid";
import type ComfyGraphCanvas from '$lib/ComfyGraphCanvas';
import { blankGraph } from '$lib/defaultGraph';
import type { SerializedAppState } from '$lib/components/ComfyApp';
type ActiveCanvas = {
canvas: LGraphCanvas | null;
canvasHandler: () => void | null;
state: SerializedGraphCanvasState;
}
export type SerializedWorkflowState = {
graph: SerializedLGraph,
layout: SerializedLayoutState,
attrs: WorkflowAttributes
}
/*
* ID for an opened workflow.
*
* Unlike NodeID and PromptID, these are *not* saved to the workflow itself.
* They are only used for identifying an open workflow in the program. If the
* workflow is closed and reopened, a different workflow ID will be assigned to
* it.
*/
export type WorkflowInstID = UUID;
/*
* Global workflow attributes
*/
export type WorkflowAttributes = {
/*
* Title of the workflow.
*/
title: string,
/*
* Name of the "Queue Prompt" button. Set to blank to hide the button.
*/
queuePromptButtonName: string,
/*
* If true, clicking the "Queue Prompt" button will run the default
* subgraph. Set this to false if you need special behavior before running
* any subgraphs, and instead use the `onDefaultQueueAction` event of the
* Comfy.QueueEvents node.
*/
queuePromptButtonRunWorkflow: boolean,
}
export class ComfyWorkflow {
/*
* Used for uniquely identifying the instance of the opened workflow in the frontend.
*/
id: WorkflowInstID;
/*
* Graph of this workflow, whose nodes are bound to the UI layout
*/
graph: ComfyGraph;
/*
* Global workflow attributes
*/
attrs: WorkflowAttributes;
/*
* True if an unsaved modification has been detected on this workflow
*/
isModified: boolean = false;
get layout(): WritableLayoutStateStore | null {
return layoutStates.getLayout(this.id)
}
/*
* Graph canvases attached to the graph of this workflow
*/
canvases: Record<string, ActiveCanvas> = {};
constructor(title: string) {
this.id = uuidv4();
this.attrs = {
...defaultWorkflowAttributes,
title,
}
this.graph = new ComfyGraph(this.id);
}
notifyModified() {
this.isModified = true;
store.set(get(store));
}
setAttribute<K extends keyof WorkflowAttributes>(key: K, value: WorkflowAttributes[K]) {
this.attrs[key] = value;
this.notifyModified();
}
start(key: string, canvas: ComfyGraphCanvas) {
if (this.canvases[key] != null)
throw new Error(`This workflow is already being displayed on canvas ${key}`)
const canvasHandler = () => canvas.draw(true);
this.canvases[key] = {
canvas,
canvasHandler,
state: {
// TODO
offset: [0, 0],
scale: 1
}
}
this.graph.attachCanvas(canvas);
this.graph.eventBus.on("afterExecute", canvasHandler)
if (Object.keys(this.canvases).length === 1)
this.graph.start();
}
stop(key: string) {
const canvas = this.canvases[key]
if (canvas == null) {
console.debug("This workflow is not being displayed on canvas ${key}")
return;
}
this.graph.detachCanvas(canvas.canvas);
this.graph.eventBus.removeListener("afterExecute", canvas.canvasHandler)
delete this.canvases[key]
if (Object.keys(this.canvases).length === 0)
this.graph.stop();
}
stopAll() {
for (const key of Object.keys(this.canvases))
this.stop(key)
this.graph.stop()
}
serialize(layoutState: WritableLayoutStateStore): SerializedWorkflowState {
const graph = this.graph;
const serializedGraph = graph.serialize()
const serializedLayout = layoutState.serialize()
return {
graph: serializedGraph,
layout: serializedLayout,
attrs: this.attrs
}
}
static create(title: string = "New Workflow"): [ComfyWorkflow, WritableLayoutStateStore] {
const workflow = new ComfyWorkflow(title);
const layoutState = layoutStates.create(workflow);
return [workflow, layoutState]
}
deserialize(layoutState: WritableLayoutStateStore, data: SerializedWorkflowState) {
// 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
// want to restore the layoutState ourselves
layoutState.onStartConfigure();
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
for (let n of data.graph.nodes) {
if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
}
this.graph.configure(data.graph);
this.graph.workflowID = this.id;
for (const node of this.graph._nodes) {
const size = node.computeSize();
size[0] = Math.max(node.size[0], size[0]);
size[1] = Math.max(node.size[1], size[1]);
node.size = size;
// this.#invokeExtensions("loadedGraphNode", node);
}
this.attrs = data.attrs;
// Now restore the layout
// Subsequent added nodes will add the UI data to layoutState
// TODO
layoutState.deserialize(data.layout, this.graph)
}
}
export type WorkflowState = {
openedWorkflows: ComfyWorkflow[],
openedWorkflowsByID: Record<WorkflowInstID, ComfyWorkflow>,
activeWorkflowID: WorkflowInstID | null,
activeWorkflow: ComfyWorkflow | null,
}
type WorkflowStateOps = {
getWorkflow: (id: WorkflowInstID) => ComfyWorkflow | null
getWorkflowByGraph: (graph: LGraph) => ComfyWorkflow | null
getWorkflowByNode: (node: LGraphNode) => ComfyWorkflow | null
getWorkflowByNodeID: (id: NodeID) => ComfyWorkflow | null
getActiveWorkflow: () => ComfyWorkflow | null
createNewWorkflow: (canvas: ComfyGraphCanvas, title?: string, setActive?: boolean) => ComfyWorkflow,
openWorkflow: (canvas: ComfyGraphCanvas, data: SerializedAppState) => ComfyWorkflow,
closeWorkflow: (canvas: ComfyGraphCanvas, index: number) => void,
closeAllWorkflows: (canvas: ComfyGraphCanvas) => void,
setActiveWorkflow: (canvas: ComfyGraphCanvas, index: number) => ComfyWorkflow | null
}
export type WritableWorkflowStateStore = Writable<WorkflowState> & WorkflowStateOps;
const store: Writable<WorkflowState> = writable(
{
openedWorkflows: [],
openedWorkflowsByID: {},
activeWorkflowID: null,
activeWorkflow: null
})
function getWorkflow(id: WorkflowInstID): ComfyWorkflow | null {
return get(store).openedWorkflowsByID[id];
}
function getWorkflowByGraph(graph: LGraph): ComfyWorkflow | null {
const workflowID = (graph.getRootGraph() as ComfyGraph)?.workflowID;
if (workflowID == null)
return null;
return getWorkflow(workflowID);
}
function getWorkflowByNode(node: LGraphNode): ComfyWorkflow | null {
return getWorkflowByGraph(node.graph);
}
function getWorkflowByNodeID(id: NodeID): ComfyWorkflow | null {
return Object.values(get(store).openedWorkflows).find(w => {
return w.graph.getNodeByIdRecursive(id) != null
})
}
function getActiveWorkflow(): ComfyWorkflow | null {
const state = get(store);
if (state.activeWorkflowID == null)
return null;
return state.openedWorkflowsByID[state.activeWorkflowID];
}
function createNewWorkflow(canvas: ComfyGraphCanvas, title: string = "New Workflow", setActive: boolean = false): ComfyWorkflow {
const workflow = new ComfyWorkflow(title);
const layoutState = layoutStates.create(workflow);
layoutState.initDefaultLayout();
const state = get(store);
state.openedWorkflows.push(workflow);
state.openedWorkflowsByID[workflow.id] = workflow;
if (setActive)
setActiveWorkflow(canvas, state.openedWorkflows.length - 1)
store.set(state)
return workflow;
}
function openWorkflow(canvas: ComfyGraphCanvas, data: SerializedAppState): ComfyWorkflow {
const [workflow, layoutState] = ComfyWorkflow.create("Workflow")
workflow.deserialize(layoutState, { graph: data.workflow, layout: data.layout, attrs: data.attrs })
const state = get(store);
state.openedWorkflows.push(workflow);
state.openedWorkflowsByID[workflow.id] = workflow;
setActiveWorkflow(canvas, state.openedWorkflows.length - 1)
store.set(state)
return workflow;
}
function closeWorkflow(canvas: ComfyGraphCanvas, index: number) {
const state = get(store);
if (index < 0 || index >= state.openedWorkflows.length)
return;
const workflow = state.openedWorkflows[index];
workflow.stopAll();
layoutStates.remove(workflow.id)
state.openedWorkflows.splice(index, 1)
delete state.openedWorkflowsByID[workflow.id]
let newIndex = clamp(index, 0, state.openedWorkflows.length - 1)
setActiveWorkflow(canvas, newIndex);
store.set(state);
}
function closeAllWorkflows(canvas: ComfyGraphCanvas) {
const state = get(store)
while (state.openedWorkflows.length > 0)
closeWorkflow(canvas, 0)
}
function setActiveWorkflow(canvas: ComfyGraphCanvas, index: number): ComfyWorkflow | null {
const state = get(store);
if (state.openedWorkflows.length === 0) {
state.activeWorkflowID = null;
state.activeWorkflow = null
return null;
}
if (index < 0 || index >= state.openedWorkflows.length)
return state.activeWorkflow;
const workflow = state.openedWorkflows[index]
if (workflow.id === state.activeWorkflowID)
return;
if (state.activeWorkflow != null)
state.activeWorkflow.stop("app")
state.activeWorkflowID = workflow.id;
state.activeWorkflow = workflow;
workflow.start("app", canvas);
canvas.deserialize(workflow.canvases["app"].state)
store.set(state)
return workflow;
}
const workflowStateStore: WritableWorkflowStateStore =
{
...store,
getWorkflow,
getWorkflowByGraph,
getWorkflowByNode,
getWorkflowByNodeID,
getActiveWorkflow,
createNewWorkflow,
openWorkflow,
closeWorkflow,
closeAllWorkflows,
setActiveWorkflow,
}
export default workflowStateStore;

View File

@@ -1,10 +1,11 @@
import layoutState, { type WidgetLayout } from "$lib/stores/layoutState";
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 } from "@litegraph-ts/core";
import { Subgraph, type LGraph, type LGraphNode, type LLink, type SerializedLGraph, type UUID, type NodeID } 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";
export function clamp(n: number, min: number, max: number): number {
return Math.min(Math.max(n, min), max)
@@ -39,7 +40,7 @@ export function download(filename: string, text: string, type: string = "text/pl
}, 0);
}
export function startDrag(evt: MouseEvent) {
export function startDrag(evt: MouseEvent, layoutState: WritableLayoutStateStore) {
const dragItemId: string = evt.target.dataset["dragItemId"];
const ss = get(selectionState)
const ls = get(layoutState)
@@ -78,9 +79,11 @@ export function startDrag(evt: MouseEvent) {
layoutState.set(ls)
selectionState.set(ss)
layoutState.notifyWorkflowModified();
};
export function stopDrag(evt: MouseEvent) {
export function stopDrag(evt: MouseEvent, layoutState: WritableLayoutStateStore) {
layoutState.notifyWorkflowModified();
};
export function graphToGraphVis(graph: LGraph): string {
@@ -175,23 +178,33 @@ export function workflowToGraphVis(workflow: SerializedLGraph): string {
export function promptToGraphVis(prompt: SerializedPrompt): string {
let out = "digraph {\n"
const ids: Record<NodeID, number> = {}
let nextID = 0;
for (const pair of Object.entries(prompt.output)) {
const [id, o] = pair;
const outNode = prompt.workflow.nodes.find(n => n.id == id)
if (outNode) {
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 inpNode = prompt.workflow.nodes.find(n => n.id == i[0])
const [inpID, inpSlot] = i;
if (ids[inpID] == null)
ids[inpID] = nextID++;
const inpNode = prompt.output[inpID]
if (inpNode) {
out += `"${inpNode.title}" -> "${outNode.title}"\n`
out += `"${ids[inpID]}_${inpNode.class_type}" -> "${ids[id]}_${o.class_type}"\n`
}
}
else {
const value = String(i).substring(0, 20)
// Value
out += `"${id}-${inpName}-${i}" -> "${outNode.title}"\n`
out += `"${ids[id]}-${inpName}-${value}" -> "${ids[id]}_${o.class_type}"\n`
}
}
}
@@ -202,13 +215,15 @@ export function promptToGraphVis(prompt: SerializedPrompt): string {
}
export function getNodeInfo(nodeId: ComfyNodeID): string {
let app = (window as any).app;
if (!app || !app.lGraph)
return String(nodeId);
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);
const title = app.lGraph.getNodeByIdRecursive(nodeId)?.title || String(nodeId);
return title + " (" + displayNodeID + ")"
}
@@ -222,7 +237,7 @@ export const debounce = (callback: Function, wait = 250) => {
};
};
export function convertComfyOutputToGradio(output: ComfyExecutionResult): GradioFileData[] {
export function convertComfyOutputToGradio(output: SerializedPromptOutput): GradioFileData[] {
return output.images.map(convertComfyOutputEntryToGradio);
}
@@ -238,7 +253,7 @@ export function convertComfyOutputEntryToGradio(r: ComfyImageLocation): GradioFi
return fileData
}
export function convertComfyOutputToComfyURL(output: FileNameOrGalleryData): string {
export function convertComfyOutputToComfyURL(output: string | ComfyImageLocation): string {
if (typeof output === "string")
return output;
@@ -329,11 +344,16 @@ export async function uploadImageToComfyUI(blob: Blob, filename: string, type: C
}
/** Raw output as received from ComfyUI's backend */
export interface ComfyExecutionResult {
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 */
@@ -375,7 +395,7 @@ export function isComfyBoxImageMetadataArray(value: any): value is ComfyBoxImage
return Array.isArray(value) && value.every(isComfyBoxImageMetadata);
}
export function isComfyExecutionResult(value: any): value is ComfyExecutionResult {
export function isComfyExecutionResult(value: any): value is SerializedPromptOutput {
return value && typeof value === "object" && Array.isArray(value.images)
}
@@ -415,7 +435,7 @@ export function comfyFileToAnnotatedFilepath(comfyUIFile: ComfyImageLocation): s
return path;
}
export function executionResultToImageMetadata(result: ComfyExecutionResult): ComfyBoxImageMetadata[] {
export function executionResultToImageMetadata(result: SerializedPromptOutput): ComfyBoxImageMetadata[] {
return result.images.map(comfyFileToComfyBoxMetadata)
}

View File

@@ -2,11 +2,12 @@ import { LGraphNode, LiteGraph } from "@litegraph-ts/core";
import type IComfyInputSlot from "./IComfyInputSlot";
import type { ComfyInputConfig } from "./IComfyInputSlot";
import { ComfyComboNode, ComfyNumberNode, ComfyTextNode } from "./nodes/widgets";
import type { ComfyNodeDefInput } from "./ComfyNodeDef";
type WidgetFactory = (node: LGraphNode, inputName: string, inputData: any) => IComfyInputSlot;
type WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput) => IComfyInputSlot;
function getNumberDefaults(inputData: any, defaultStep: number): ComfyInputConfig {
let defaultValue = inputData[1]["default"];
function getNumberDefaults(inputData: ComfyNodeDefInput, defaultStep: number): ComfyInputConfig {
let defaultValue = inputData[1].default;
let { min, max, step } = inputData[1];
if (defaultValue == undefined) defaultValue = 0;
@@ -33,25 +34,25 @@ function addComfyInput(node: LGraphNode, inputName: string, extraInfo: Partial<I
return input;
}
const FLOAT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const FLOAT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 0.5);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode })
}
const INT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const INT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 1);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode })
};
const STRING: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const STRING: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const defaultValue = inputData[1].default || "";
const multiline = !!inputData[1].multiline;
return addComfyInput(node, inputName, { type: "string", config: { defaultValue, multiline }, defaultWidgetNode: ComfyTextNode })
};
const COMBO: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const type = inputData[0];
const COMBO: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
const type = inputData[0] as string[];
let defaultValue = type[0];
if (inputData[1] && inputData[1].default) {
defaultValue = inputData[1].default;
@@ -59,7 +60,7 @@ const COMBO: WidgetFactory = (node: LGraphNode, inputName: string, inputData: an
return addComfyInput(node, inputName, { type: "string", config: { values: type, defaultValue }, defaultWidgetNode: ComfyComboNode })
}
const IMAGEUPLOAD: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const IMAGEUPLOAD: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => {
return addComfyInput(node, inputName, { type: "number", config: {} })
}

View File

@@ -1,21 +1,23 @@
<script lang="ts">
import { type WidgetLayout } from "$lib/stores/layoutState";
import { type WidgetLayout } from "$lib/stores/layoutStates";
import { Button } from "@gradio/button";
import { get, type Writable, writable } from "svelte/store";
import { isDisabled } from "./utils"
import type { ComfyButtonNode } from "$lib/nodes/widgets";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyButtonNode | null = null;
let nodeValue: Writable<boolean> | null = null;
let attrsChanged: Writable<boolean> | null = null;
// let nodeValue: Writable<boolean> = writable(false);
let attrsChanged: Writable<number> = writable(0);
$: widget && setNodeValue(widget);
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfyButtonNode
nodeValue = node.value;
// nodeValue = node.value;
attrsChanged = widget.attrsChanged;
}
};
@@ -26,7 +28,7 @@
}
const style = {
full_width: "100%",
full_width: true
}
</script>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { type WidgetLayout } from "$lib/stores/layoutState";
import { type WidgetLayout } from "$lib/stores/layoutStates";
import { Block } from "@gradio/atoms";
import { Checkbox } from "@gradio/form";
import { get, type Writable, writable } from "svelte/store";

View File

@@ -5,7 +5,7 @@
// import VirtualList from '$lib/components/VirtualList.svelte';
import VirtualList from 'svelte-tiny-virtual-list';
import type { ComfyComboNode } from "$lib/nodes/widgets";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { type WidgetLayout } from "$lib/stores/layoutStates";
import { get, writable, type Writable } from "svelte/store";
import { isDisabled } from "./utils"
export let widget: WidgetLayout | null = null;

View File

@@ -5,7 +5,7 @@
import { Image } from "@gradio/icons";
import { StaticImage } from "$lib/components/gradio/image";
import type { Styles } from "@gradio/utils";
import type { WidgetLayout } from "$lib/stores/layoutState";
import type { WidgetLayout } from "$lib/stores/layoutStates";
import { writable, type Writable } from "svelte/store";
import type { FileData as GradioFileData } from "@gradio/upload";
import type { SelectData as GradioSelectData } from "@gradio/utils";

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { type WidgetLayout } from "$lib/stores/layoutState";
import { type WidgetLayout } from "$lib/stores/layoutStates";
import { Block } from "@gradio/atoms";
import { TextBox } from "@gradio/form";
import Row from "$lib/components/gradio/app/Row.svelte";

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { ComfyNumberNode } from "$lib/nodes/widgets";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { type WidgetLayout } from "$lib/stores/layoutStates";
import { Range } from "$lib/components/gradio/form";
import { get, type Writable } from "svelte/store";
import { debounce } from "$lib/utils";

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { type WidgetLayout } from "$lib/stores/layoutState";
import { type WidgetLayout } from "$lib/stores/layoutStates";
import { Block } from "@gradio/atoms";
import { Radio } from "@gradio/form";
import { get, type Writable, writable } from "svelte/store";

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { TextBox } from "@gradio/form";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { type WidgetLayout } from "$lib/stores/layoutStates";
import { type Writable } from "svelte/store";
import { isDisabled } from "./utils"
import type { ComfyTextNode } from "$lib/nodes/widgets";

View File

@@ -1,5 +1,4 @@
import type { IDragItem } from "$lib/stores/layoutState";
import layoutState from "$lib/stores/layoutState";
import type { IDragItem } from "$lib/stores/layoutStates";
import { LGraphNode, NodeMode } from "@litegraph-ts/core";
import { get } from "svelte/store";

View File

@@ -1,33 +1,35 @@
<script lang="ts">
import ComfyApp, { type SerializedAppState } from "$lib/components/ComfyApp";
import notify from "$lib/notify";
import uiState from "$lib/stores/uiState";
import layoutState from "$lib/stores/layoutState";
import queueState from "$lib/stores/queueState";
import workflowState, { ComfyWorkflow } from "$lib/stores/workflowState";
import { getNodeInfo } from "$lib/utils"
import { Link, Toolbar } from "framework7-svelte"
import ProgressBar from "$lib/components/ProgressBar.svelte";
import Indicator from "./Indicator.svelte";
import interfaceState from "$lib/stores/interfaceState";
import LightboxModal from "$lib/components/LightboxModal.svelte";
import type { WritableLayoutStateStore } from "$lib/stores/layoutStates";
export let subworkflowID: number = -1;
export let app: ComfyApp = undefined;
let layoutState: WritableLayoutStateStore = null;
let fileInput: HTMLInputElement = undefined;
let workflow: ComfyWorkflow | null = null;
$: workflow = $workflowState.activeWorkflow;
function queuePrompt() {
navigator.vibrate(20)
app.runDefaultQueueAction()
}
function refreshCombos() {
async function refreshCombos() {
navigator.vibrate(20)
app.refreshComboInNodes()
await app.refreshComboInNodes()
}
function doSave(): void {
if (!app?.lGraph || !fileInput)
if (!fileInput)
return;
navigator.vibrate(20)
@@ -35,7 +37,7 @@
}
function doLoad(): void {
if (!app?.lGraph || !fileInput)
if (!fileInput)
return;
navigator.vibrate(20)
@@ -48,9 +50,6 @@
}
function doSaveLocal(): void {
if (!app?.lGraph)
return;
navigator.vibrate(20)
app.saveStateToLocalStorage();
}
@@ -74,9 +73,9 @@
{/if}
</div>
<Toolbar bottom>
{#if $layoutState.attrs.queuePromptButtonName != ""}
{#if workflow != null && workflow.attrs.queuePromptButtonName != ""}
<Link on:click={queuePrompt}>
{$layoutState.attrs.queuePromptButtonName}
{workflow.attrs.queuePromptButtonName}
</Link>
{/if}
<Link on:click={refreshCombos}>🔄</Link>

View File

@@ -18,21 +18,22 @@
lCanvas.draw(true, true);
}
$: if (app != null && app.lGraph && canvasEl != null) {
if (!lCanvas) {
lCanvas = new ComfyGraphCanvas(app, canvasEl);
lCanvas.allow_interaction = false;
app.lGraph.eventBus.on("afterExecute", () => lCanvas.draw(true))
}
resizeCanvas();
}
// TODO
// $: if (app?.activeGraph != null && canvasEl != null) {
// if (!lCanvas) {
// lCanvas = new ComfyGraphCanvas(app, app.activeGraph, canvasEl);
// lCanvas.allow_interaction = false;
// app.activeGraph.eventBus.on("afterExecute", () => lCanvas.draw(true))
// }
// resizeCanvas();
// }
</script>
<Page>
<Navbar title="Node Graph" backLink="Back" />
<div class="canvas-wrapper pane-wrapper">
<canvas bind:this={canvasEl} id="extra-canvas" />
<!-- <canvas bind:this={canvasEl} id="extra-canvas" /> -->
</div>
</Page>

View File

@@ -6,9 +6,9 @@
export let app: ComfyApp | null = null;
async function doLoadDefault() {
var confirmed = confirm("Are you sure you want to clear the current workflow and load the default graph?");
var confirmed = confirm("Would you like to load the default workflow in a new tab?");
if (confirmed) {
await app.initDefaultGraph();
await app.initDefaultWorkflow();
}
}
</script>

View File

@@ -1,21 +1,29 @@
<script lang="ts">
import layoutState, { type IDragItem } from "$lib/stores/layoutState";
import { Page, Navbar, Link, BlockTitle, Block, List, ListItem, Toolbar } from "framework7-svelte"
import WidgetContainer from "$lib/components/WidgetContainer.svelte";
import type ComfyApp from "$lib/components/ComfyApp";
import type { ComfyWorkflow } from "$lib/components/ComfyApp";
import { writable, type Writable } from "svelte/store";
import type { WritableLayoutStateStore } from "$lib/stores/layoutStates";
export let subworkflowID: number = -1;
export let app: ComfyApp
// TODO move
let workflow: ComfyWorkflow | null = null
let layoutState: WritableLayoutStateStore | null = null;
$: layoutState = workflow ? workflow.layout : null;
</script>
<Page name="subworkflow">
<Navbar title="Workflow {subworkflowID}" backLink="Back" />
{#if layoutState}
<div class="container">
<WidgetContainer bind:dragItem={$layoutState.root} isMobile={true} classes={["root-container", "mobile"]} />
<WidgetContainer bind:dragItem={$layoutState.root} {layoutState} isMobile={true} classes={["root-container", "mobile"]} />
</div>
{/if}
</Page>
<style lang="scss">

View File

@@ -7,13 +7,14 @@ import ComfyPromptSerializer from "$lib/components/ComfyPromptSerializer";
import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode";
import ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import { graphToGraphVis } from "$lib/utils";
import layoutState from "$lib/stores/layoutState";
import { ComfyNumberNode } from "$lib/nodes/widgets";
import { get } from "svelte/store";
import layoutStates from "$lib/stores/layoutStates";
import { ComfyWorkflow } from "$lib/stores/workflowState";
export default class ComfyGraphTests extends UnitTest {
test__onNodeAdded__updatesLayoutState() {
const graph = new ComfyGraph();
const [{ graph }, layoutState] = ComfyWorkflow.create()
layoutState.initDefaultLayout() // adds 3 containers
const state = get(layoutState)
@@ -38,7 +39,7 @@ export default class ComfyGraphTests extends UnitTest {
}
test__onNodeAdded__handlesNodesAddedInSubgraphs() {
const graph = new ComfyGraph();
const [{ graph }, layoutState] = ComfyWorkflow.create()
layoutState.initDefaultLayout()
const subgraph = LiteGraph.createNode(Subgraph);
@@ -57,7 +58,7 @@ export default class ComfyGraphTests extends UnitTest {
}
test__onNodeAdded__handlesSubgraphsWithNodes() {
const graph = new ComfyGraph();
const [{ graph }, layoutState] = ComfyWorkflow.create()
layoutState.initDefaultLayout()
const state = get(layoutState)
@@ -75,7 +76,7 @@ export default class ComfyGraphTests extends UnitTest {
}
test__onNodeRemoved__updatesLayoutState() {
const graph = new ComfyGraph();
const [{ graph }, layoutState] = ComfyWorkflow.create()
layoutState.initDefaultLayout()
const widget = LiteGraph.createNode(ComfyNumberNode);

View File

@@ -1,4 +1,4 @@
import { LGraph, LiteGraph, Subgraph, type SlotLayout } from "@litegraph-ts/core"
import { LGraph, LiteGraph, Subgraph, type SlotLayout, NodeMode } from "@litegraph-ts/core"
import { Watch } from "@litegraph-ts/nodes-basic"
import { expect } from 'vitest'
import UnitTest from "./UnitTest"
@@ -139,6 +139,38 @@ export default class ComfyPromptSerializerTests extends UnitTest {
expect(result.output[output.id].inputs).toEqual({})
}
test__serialize__shouldIgnoreInactiveSubgraphs() {
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(subgraph)
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)
subgraph.mode = NodeMode.NEVER;
const result = ser.serialize(graph)
expect(Object.keys(result.output)).toHaveLength(2);
expect(result.output[input.id].inputs["in"]).toBeUndefined();
expect(result.output[link.id]).toBeUndefined();
expect(result.output[output.id].inputs).toEqual({})
}
test__serialize__shouldFollowSubgraphsRecursively() {
const ser = new ComfyPromptSerializer();
const graph = new ComfyGraph();
@@ -178,4 +210,42 @@ export default class ComfyPromptSerializerTests extends UnitTest {
expect(result.output[link.id].inputs["in"][0]).toEqual(output.id)
expect(result.output[output.id].inputs).toEqual({})
}
test__serialize__shouldIgnoreInactiveSubgraphsRecursively() {
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)
subgraphA.mode = NodeMode.NEVER;
const result = ser.serialize(graph)
expect(Object.keys(result.output)).toHaveLength(2);
expect(result.output[input.id].inputs["in"]).toBeUndefined();
expect(result.output[link.id]).toBeUndefined();
expect(result.output[output.id].inputs).toEqual({})
}
}