Merge pull request #71 from space-nuko/subgraph-templates3

Subgraph templates
This commit is contained in:
space-nuko
2023-05-25 13:57:08 -05:00
57 changed files with 9407 additions and 2037 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,7 @@
"@litegraph-ts/tsconfig": "workspace:*",
"@sveltejs/vite-plugin-svelte": "^2.1.1",
"@tsconfig/svelte": "^4.0.1",
"@types/dompurify": "^3.0.2",
"@zerodevx/svelte-json-view": "^1.0.5",
"canvas-to-svg": "^1.0.3",
"cm6-theme-basic-dark": "^0.2.0",
@@ -79,6 +80,7 @@
"codemirror": "^6.0.1",
"csv": "^6.3.0",
"csv-parse": "^5.3.10",
"dompurify": "^3.0.3",
"events": "^3.3.0",
"framework7": "^8.0.3",
"framework7-svelte": "^8.0.3",
@@ -88,8 +90,8 @@
"radix-icons-svelte": "^1.2.1",
"style-mod": "^4.0.3",
"svelte-bootstrap-icons": "^2.3.1",
"svelte-codemirror-editor": "^1.1.0",
"svelte-feather-icons": "^4.0.0",
"svelte-floating-ui": "^1.5.2",
"svelte-preprocess": "^5.0.3",
"svelte-select": "^5.5.3",
"svelte-splitpanes": "^0.7.13",

41
pnpm-lock.yaml generated
View File

@@ -94,6 +94,9 @@ importers:
'@tsconfig/svelte':
specifier: ^4.0.1
version: 4.0.1
'@types/dompurify':
specifier: ^3.0.2
version: 3.0.2
'@zerodevx/svelte-json-view':
specifier: ^1.0.5
version: 1.0.5(svelte@3.58.0)
@@ -115,6 +118,9 @@ importers:
csv-parse:
specifier: ^5.3.10
version: 5.3.10
dompurify:
specifier: ^3.0.3
version: 3.0.3
events:
specifier: ^3.3.0
version: 3.3.0
@@ -142,12 +148,12 @@ importers:
svelte-bootstrap-icons:
specifier: ^2.3.1
version: 2.3.1
svelte-codemirror-editor:
specifier: ^1.1.0
version: 1.1.0(codemirror@6.0.1)
svelte-feather-icons:
specifier: ^4.0.0
version: 4.0.0
svelte-floating-ui:
specifier: ^1.5.2
version: 1.5.2
svelte-preprocess:
specifier: ^5.0.3
version: 5.0.3(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3)
@@ -3235,6 +3241,12 @@ packages:
resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==}
dev: true
/@types/dompurify@3.0.2:
resolution: {integrity: sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ==}
dependencies:
'@types/trusted-types': 2.0.3
dev: false
/@types/estree@1.0.1:
resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==}
@@ -3318,6 +3330,10 @@ packages:
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
dev: true
/@types/trusted-types@2.0.3:
resolution: {integrity: sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==}
dev: false
/@types/uuid@9.0.1:
resolution: {integrity: sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==}
dev: false
@@ -4562,6 +4578,10 @@ packages:
domelementtype: 2.3.0
dev: false
/dompurify@3.0.3:
resolution: {integrity: sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ==}
dev: false
/domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
dependencies:
@@ -8097,14 +8117,6 @@ packages:
- sugarss
dev: true
/svelte-codemirror-editor@1.1.0(codemirror@6.0.1):
resolution: {integrity: sha512-wFdMIsZds5qzn3x2NbFUxDVU6Cn3rwFdq0035ypaFVgzTjJ90bnPm6IbrFA4OJz1ngIyfbIuPAPDjm7rJIr0gg==}
peerDependencies:
codemirror: ^6.0.0
dependencies:
codemirror: 6.0.1
dev: false
/svelte-dnd-action@0.9.22(svelte@3.58.0):
resolution: {integrity: sha512-lOQJsNLM1QWv5mdxIkCVtk6k4lHCtLgfE59y8rs7iOM6erchbLC9hMEFYSveZz7biJV0mpg7yDSs4bj/RT/YkA==}
peerDependencies:
@@ -8126,6 +8138,13 @@ packages:
'@floating-ui/dom': 1.2.8
dev: false
/svelte-floating-ui@1.5.2:
resolution: {integrity: sha512-nV50eno74CEsFfFJ6iyN/oNYDEOck1TZjGV3lmJksVRbiiUAVF6bHspyAhR7GZ7c/4qbRWp9UyX24J+UXdEpag==}
dependencies:
'@floating-ui/core': 1.2.6
'@floating-ui/dom': 1.2.8
dev: false
/svelte-hmr@0.15.1(svelte@3.58.0):
resolution: {integrity: sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==}
engines: {node: ^12.20 || ^14.13.1 || >= 16}

7
public/config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"comfyUIHostname": "localhost",
"comfyUIPort": 8188,
"alwaysStripUserState": false,
"promptForWorkflowName": false,
"confirmWhenUnloadingUnsavedChanges": true
}

BIN
public/image/graph-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"comfyBoxWorkflow": true,
"createdBy": "ComfyBox",
"version": 1,
"commitHash": "test",
"commitHash": "574d3170a4e829df366dc12c3aaa049121052d8f\n",
"workflow": {
"last_node_id": 0,
"last_link_id": 0,
@@ -176,7 +176,7 @@
"widgets_values": [],
"color": "#223",
"bgColor": "#335",
"comfyValue": "A",
"comfyValue": null,
"shownOutputProperties": {},
"saveUserState": false
},
@@ -391,7 +391,20 @@
"title": "UI.Gallery",
"properties": {
"tags": [],
"defaultValue": null,
"defaultValue": [
{
"isComfyBoxImageMetadata": true,
"comfyUIFile": {
"filename": "ComfyUI_04712_.png",
"subfolder": "",
"type": "output"
},
"name": "File",
"tags": [],
"width": 6656,
"height": 4096
}
],
"index": 0,
"updateMode": "replace",
"autoSelectOnUpdate": true
@@ -399,7 +412,20 @@
"widgets_values": [],
"color": "#223",
"bgColor": "#335",
"comfyValue": [],
"comfyValue": [
{
"isComfyBoxImageMetadata": true,
"comfyUIFile": {
"filename": "ComfyUI_04712_.png",
"subfolder": "",
"type": "output"
},
"name": "File",
"tags": [],
"width": 6656,
"height": 4096
}
],
"shownOutputProperties": {},
"saveUserState": false
},
@@ -502,13 +528,39 @@
],
"title": "UI.ImageUpload",
"properties": {
"defaultValue": null,
"defaultValue": [
{
"isComfyBoxImageMetadata": true,
"comfyUIFile": {
"filename": "ComfyUI_05835_.png",
"type": "output",
"subfolder": ""
},
"name": "File",
"tags": [],
"width": 640,
"height": 768
}
],
"tags": []
},
"widgets_values": [],
"color": "#223",
"bgColor": "#335",
"comfyValue": [],
"comfyValue": [
{
"isComfyBoxImageMetadata": true,
"comfyUIFile": {
"filename": "ComfyUI_05835_.png",
"type": "output",
"subfolder": ""
},
"name": "File",
"tags": [],
"width": 640,
"height": 768
}
],
"shownOutputProperties": {},
"saveUserState": false
},
@@ -654,7 +706,8 @@
"openOnStartup": false,
"buttonVariant": "primary",
"buttonSize": "large",
"tags": []
"tags": [],
"destroyChildOnCLose": false
}
},
"children": [
@@ -679,7 +732,8 @@
"openOnStartup": false,
"buttonVariant": "primary",
"buttonSize": "large",
"tags": []
"tags": [],
"destroyChildOnCLose": false
}
},
"children": [
@@ -705,7 +759,8 @@
"openOnStartup": false,
"buttonVariant": "primary",
"buttonSize": "large",
"tags": []
"tags": [],
"destroyChildOnCLose": false
}
},
"children": [
@@ -732,7 +787,8 @@
"openOnStartup": false,
"buttonVariant": "primary",
"buttonSize": "large",
"tags": []
"tags": [],
"destroyChildOnCLose": false
}
},
"children": [],
@@ -744,7 +800,7 @@
"id": "2f0371e8-559e-4a58-a5d1-0a50117675fc",
"nodeId": "578edfae-2767-4b23-9a3b-6edd8ccad1dd",
"attrs": {
"title": "model_name",
"title": "Modal Name",
"hidden": false,
"disabled": false,
"direction": "vertical",
@@ -756,7 +812,8 @@
"openOnStartup": false,
"buttonVariant": "primary",
"buttonSize": "large",
"tags": []
"tags": [],
"destroyChildOnCLose": false
}
},
"children": [],
@@ -779,7 +836,8 @@
"openOnStartup": false,
"buttonVariant": "primary",
"buttonSize": "large",
"tags": []
"tags": [],
"destroyChildOnCLose": false
}
},
"children": [
@@ -793,7 +851,7 @@
"id": "db12215f-fd9c-463f-9fbd-26b79e994e0e",
"nodeId": "59feacdf-de02-4b1e-b8ba-e219ba5126b7",
"attrs": {
"title": "filename_prefix",
"title": "Filename Prefix",
"hidden": false,
"disabled": false,
"direction": "vertical",
@@ -805,7 +863,8 @@
"openOnStartup": false,
"buttonVariant": "primary",
"buttonSize": "large",
"tags": []
"tags": [],
"destroyChildOnCLose": false
}
},
"children": [],
@@ -828,7 +887,8 @@
"openOnStartup": false,
"buttonVariant": "primary",
"buttonSize": "large",
"tags": []
"tags": [],
"destroyChildOnCLose": false
}
},
"children": [
@@ -849,12 +909,13 @@
"classes": "",
"style": "",
"nodeDisabledState": "hidden",
"variant": "gallery",
"variant": "image",
"containerVariant": "hidden",
"openOnStartup": false,
"buttonVariant": "primary",
"buttonSize": "large",
"tags": []
"tags": [],
"destroyChildOnCLose": false
}
},
"children": [],

8
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare type Item = import('svelte-dnd-action').Item;
declare type DndEvent<ItemType = Item> = import('svelte-dnd-action').DndEvent<ItemType>;
declare namespace svelte.JSX {
interface HTMLAttributes<T> {
onconsider?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void;
onfinalize?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void;
}
}

View File

@@ -1,9 +1,11 @@
import { Subgraph, type LGraphNode, type LLink, type SerializedLGraphNode, type SerializedLLink, LGraph } from "@litegraph-ts/core"
import { Subgraph, type LGraphNode, type LLink, type SerializedLGraphNode, type SerializedLLink, LGraph, type NodeID, type UUID, type Vector2 } from "@litegraph-ts/core"
import layoutStates, { isComfyWidgetNode, type ContainerLayout, type SerializedDragEntry, type WidgetLayout, type DragItemID, type WritableLayoutStateStore, type DragItemEntry, type SerializedLayoutState } from "./stores/layoutStates"
import type { ComfyWidgetNode } from "./nodes/widgets"
import type ComfyGraphCanvas from "./ComfyGraphCanvas"
import C2S from "canvas-to-svg";
import { download } from "./utils";
import { calcNodesBoundingBox, download } from "./utils";
import { v4 as uuidv4 } from "uuid";
import uiState from "./stores/uiState";
/*
* In ComfyBox a template contains a subset of nodes in the graph and the set of
@@ -11,17 +13,40 @@ import { download } from "./utils";
*/
export type ComfyBoxTemplate = {
version: 1,
id: UUID,
metadata: ComfyBoxTemplateMetadata,
nodes: LGraphNode[],
links: LLink[],
container?: DragItemEntry
}
export type SerializedTemplateLink = [NodeID, number, NodeID, number];
export type ComfyBoxTemplateMetadata = {
title: string,
author: string,
description: string,
tags: string[],
category: string,
createdAt: number
// TODO required/optional python extensions
}
/*
* In ComfyBox a template contains a subset of nodes in the graph and the set of
* components they represent in the UI.
*/
export type SerializedComfyBoxTemplate = {
isComfyBoxTemplate: true,
version: 1,
id: UUID,
commitHash: string,
/*
* Serialized metadata
*/
metadata: ComfyBoxTemplateMetadata,
/*
* Serialized nodes
@@ -31,16 +56,29 @@ export type SerializedComfyBoxTemplate = {
/*
* Serialized inner links
*/
links: SerializedLLink[],
links: SerializedTemplateLink[],
/*
* Serialized container type drag item
*/
layout?: SerializedLayoutState
/*
* SVG of the graph ndoes
*/
svg?: string
}
export type SerializedComfyBoxTemplateData = {
comfyBoxTemplate: SerializedComfyBoxTemplate
function isSerializedComfyBoxTemplate(param: any): param is SerializedComfyBoxTemplate {
return param && param.isComfyBoxTemplate;
}
const DEFAULT_TEMPLATE_METADATA = {
title: "New Template",
author: "Anonymous",
description: "A brand-new ComfyBox template",
tags: [],
category: "general"
}
export type ComfyBoxTemplateError = {
@@ -117,7 +155,7 @@ function unescapeXml(safe) {
const TEMPLATE_SVG_PADDING: number = 50;
function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, extraData: any, padding: number): string {
function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, padding: number, extraData?: SerializedComfyBoxTemplate | null): string {
// Calculate the min max bounds for the nodes on the graph
const bounds = graph._nodes.reduce(
(p, n) => {
@@ -146,6 +184,7 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, extraData: any, padd
const offset = canvas.ds.offset;
const show_info = canvas.show_info;
const background_image = canvas.background_image;
const clear_background = canvas.clear_background;
const render_canvas_border = canvas.render_canvas_border;
const render_subgraph_panels = canvas.render_subgraph_panels
const render_subgraph_stack_header = canvas.render_subgraph_stack_header
@@ -153,6 +192,7 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, extraData: any, padd
canvas.openSubgraph(graph)
canvas.show_info = false;
canvas.background_image = null;
canvas.clear_background = false;
canvas.render_canvas_border = false;
canvas.render_subgraph_panels = false;
canvas.render_subgraph_stack_header = false;
@@ -197,12 +237,10 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, extraData: any, padd
canvas.ds.offset = [-bounds[0], -bounds[1]];
canvas.ctx = svgCtx;
let saving = false;
// Trigger saving
saving = true;
canvas.isExportingSVG = true;
canvas.draw(true, true);
saving = false;
canvas.isExportingSVG = false;
// Restore original settings
canvas.closeSubgraph();
@@ -212,6 +250,7 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, extraData: any, padd
canvas.ds.offset = offset;
canvas.ctx = ctx;
canvas.show_info = show_info;
canvas.clear_background = clear_background;
canvas.background_image = background_image;
canvas.render_canvas_border = render_canvas_border;
canvas.render_subgraph_panels = render_subgraph_panels;
@@ -219,15 +258,33 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, extraData: any, padd
canvas.draw(true, true);
// Convert to SVG, embed graph and save
// const json = JSON.stringify(app.graph.serialize());
const json = JSON.stringify(extraData);
const svg = svgCtx.getSerializedSvg(true).replace("</svg>", `<desc>${escapeXml(json)}</desc></svg>`);
let svg = svgCtx.getSerializedSvg(true)
return svg
}
function pruneDetachedLinks(nodes: SerializedLGraphNode[], links: SerializedLLink[]): [SerializedLGraphNode[], SerializedLLink[]] {
export function embedTemplateInSvg(template: SerializedComfyBoxTemplate): string {
let oldSvg = template.svg;
template.svg = undefined;
const json = JSON.stringify(template);
const svg = oldSvg.replace("</svg>", `<desc>${escapeXml(json)}</desc></svg>`);
template.svg = oldSvg;
return svg
}
/*
* Moves nodes so their origin is at (0, 0)
*/
function relocateNodes(nodes: SerializedLGraphNode[]): SerializedLGraphNode[] {
let [min_x, min_y, max_x, max_y] = calcNodesBoundingBox(nodes);
for (const node of nodes) {
node.pos = [node.pos[0] - min_x, node.pos[1] - min_y];
}
return nodes;
}
function pruneDetachedLinks(nodes: SerializedLGraphNode[], links: SerializedTemplateLink[]): [SerializedLGraphNode[], SerializedTemplateLink[]] {
const nodeIds = new Set(nodes.map(n => n.id));
for (const node of nodes) {
@@ -247,12 +304,16 @@ function pruneDetachedLinks(nodes: SerializedLGraphNode[], links: SerializedLLin
}
links = links.filter(l => {
return nodeIds.has(l[1]) && nodeIds.has(l[3]);
return nodeIds.has(l[0]) && nodeIds.has(l[2]);
})
return [nodes, links]
}
function convLinkForTemplate(link: LLink): SerializedTemplateLink {
return [link.origin_id, link.origin_slot, link.target_id, link.target_slot];
}
export function serializeTemplate(canvas: ComfyGraphCanvas, template: ComfyBoxTemplate): SerializedComfyBoxTemplate {
let graph: LGraph
if (template.nodes.length === 1 && template.nodes[0].is(Subgraph)) {
@@ -266,27 +327,62 @@ export function serializeTemplate(canvas: ComfyGraphCanvas, template: ComfyBoxTe
if (layoutState == null)
throw "Couldn't find layout for template being serialized!"
uiState.update(s => { s.forceSaveUserState = false; return s; });
const metadata = template.metadata;
let nodes = template.nodes.map(n => n.serialize());
let links = template.links.map(l => l.serialize());
let links = template.links.map(convLinkForTemplate);
const layout = layoutState.serializeAtRoot(template.container.dragItem.id);
uiState.update(s => { s.forceSaveUserState = null; return s; });
nodes = relocateNodes(nodes);
[nodes, links] = pruneDetachedLinks(nodes, links);
let comfyBoxTemplate: SerializedComfyBoxTemplate = {
const svg = renderSvg(canvas, graph, TEMPLATE_SVG_PADDING);
const serTemplate: SerializedComfyBoxTemplate = {
isComfyBoxTemplate: true,
version: 1,
nodes: nodes,
links: links,
layout: layout
commitHash: __GIT_COMMIT_HASH__,
id: template.id,
metadata,
nodes,
links,
layout,
svg
}
let templateData: SerializedComfyBoxTemplateData = {
comfyBoxTemplate
}
return serTemplate;
}
const svg = renderSvg(canvas, graph, templateData, TEMPLATE_SVG_PADDING)
download("workflow.svg", svg, "image/svg+xml");
export function deserializeTemplateFromSVG(file: File): Promise<SerializedComfyBoxTemplate> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
const svg = reader.result as string;
let template = null;
return comfyBoxTemplate
// Extract embedded workflow from desc tags
const descEnd = svg.lastIndexOf("</desc>");
if (descEnd !== -1) {
const descStart = svg.lastIndexOf("<desc>", descEnd);
if (descStart !== -1) {
const json = svg.substring(descStart + 6, descEnd);
template = JSON.parse(unescapeXml(json));
}
}
if (!isSerializedComfyBoxTemplate(template)) {
reject("Invalid template format!")
}
else {
template.svg = svg;
resolve(template)
}
};
reader.readAsText(file);
});
}
@@ -303,10 +399,15 @@ export function createTemplate(nodes: LGraphNode[]): ComfyBoxTemplateResult {
const layout = layoutStates.getLayoutByNode(nodes[0])
if (layout == null) {
return {
error: "Subgraph not contained in a layout!"
error: "Node(s) not contained in a layout!"
}
}
let title = "New Template";
if (nodes.length === 1) {
title = nodes[0].title || title;
}
if (widgetNodes.length > 0) {
// Find the highest-level container that contains all these nodes and
// contains no other widgets
@@ -325,6 +426,8 @@ export function createTemplate(nodes: LGraphNode[]): ComfyBoxTemplateResult {
return {
version: 1,
id: uuidv4(),
metadata: { ...DEFAULT_TEMPLATE_METADATA, title, createdAt: Date.now() },
nodes: nodes,
links: links,
container: container
@@ -334,6 +437,8 @@ export function createTemplate(nodes: LGraphNode[]): ComfyBoxTemplateResult {
// No UI to serialize.
return {
version: 1,
id: uuidv4(),
metadata: { ...DEFAULT_TEMPLATE_METADATA, title, createdAt: Date.now() },
nodes: nodes,
links: links,
}

View File

@@ -1,4 +1,4 @@
import { LConnectionKind, LGraph, LGraphNode, type INodeSlot, type SlotIndex, LiteGraph, getStaticProperty, type LGraphAddNodeOptions, LGraphCanvas, type LGraphRemoveNodeOptions, Subgraph, type LGraphAddNodeMode } from "@litegraph-ts/core";
import { LConnectionKind, LGraph, LGraphNode, type INodeSlot, type SlotIndex, LiteGraph, getStaticProperty, type LGraphAddNodeOptions, LGraphCanvas, type LGraphRemoveNodeOptions, Subgraph, type LGraphAddNodeMode, type SerializedLGraphNode, type Vector2, type NodeID, reassignGraphIDs, type GraphIDMapping, type SerializedLGraph } from "@litegraph-ts/core";
import GraphSync from "./GraphSync";
import EventEmitter from "events";
import type TypedEmitter from "typed-emitter";
@@ -11,8 +11,10 @@ import type { ComfyComboNode, ComfyWidgetNode } from "./nodes/widgets";
import selectionState from "./stores/selectionState";
import type { WritableLayoutStateStore } from "./stores/layoutStates";
import layoutStates from "./stores/layoutStates";
import type { ComfyWorkflow, WorkflowInstID } from "./stores/workflowState";
import type { ComfyBoxWorkflow, WorkflowInstID } from "./stores/workflowState";
import workflowState from "./stores/workflowState";
import type { SerializedComfyBoxTemplate } from "./ComfyBoxTemplate";
import { v4 as uuidv4 } from "uuid"
type ComfyGraphEvents = {
configured: (graph: LGraph) => void
@@ -30,13 +32,17 @@ export default class ComfyGraph extends LGraph {
workflowID: WorkflowInstID | null = null;
get workflow(): ComfyWorkflow | null {
get workflow(): ComfyBoxWorkflow | null {
const workflowID = (this.getRootGraph() as ComfyGraph)?.workflowID;
if (workflowID == null)
return null;
return workflowState.getWorkflow(workflowID)
}
get layout(): WritableLayoutStateStore | null {
return this.workflow?.layout;
}
constructor(workflowID?: WorkflowInstID) {
super();
this.workflowID = workflowID;
@@ -217,4 +223,63 @@ export default class ComfyGraph extends LGraph {
// console.debug("ConnectionChange", node);
this.eventBus.emit("nodeConnectionChanged", kind, node, slot, targetNode, targetSlot);
}
/*
* Inserts a template.
* Layout deserialization must be handled afterwards.
* NOTE: Modifies the template in-place, be sure you cloned it beforehand!
*/
insertTemplate(template: SerializedComfyBoxTemplate, pos: Vector2): Record<NodeID, LGraphNode> {
const minPos = [0, 0]
const templateNodeIDToNewNode: Record<NodeID, LGraphNode> = {}
var nodes = [];
for (var i = 0; i < template.nodes.length; ++i) {
var node_data = template.nodes[i];
var node = LiteGraph.createNode(node_data.type);
let mapping: GraphIDMapping = null;
if (node_data.type === "graph/subgraph") {
mapping = reassignGraphIDs((node_data as any).subgraph as SerializedLGraph);
}
if (node) {
const prevNodeId = node_data.id;
node_data.id = uuidv4();
templateNodeIDToNewNode[prevNodeId] = node
node.configure(node_data);
if (mapping) {
for (const subnode of (node as Subgraph).subgraph.iterateNodesInOrderRecursive()) {
const oldNodeID = mapping.nodeIDs[subnode.id];
templateNodeIDToNewNode[oldNodeID] = subnode;
}
}
node.pos[0] += pos[0] - minPos[0]; //+= 5;
node.pos[1] += pos[1] - minPos[1]; //+= 5;
this.add(node, { doProcessChange: false, addedBy: "template" as any });
nodes.push(node);
}
}
//create links
for (var i = 0; i < template.links.length; ++i) {
var link_info = template.links[i];
var origin_node = templateNodeIDToNewNode[link_info[0]];
var target_node = templateNodeIDToNewNode[link_info[2]];
if (origin_node && target_node)
origin_node.connect(link_info[1], target_node, link_info[3]);
else
console.error("[ComfyGraphCanvas] nodes missing on template insertion!", link_info);
}
this.afterChange();
return templateNodeIDToNewNode;
}
}

View File

@@ -1,13 +1,15 @@
import { BuiltInSlotShape, LGraphCanvas, LGraphNode, LLink, LiteGraph, NodeMode, Subgraph, TitleMode, type ContextMenuItem, type IContextMenuItem, type NodeID, type Vector2, type Vector4, type MouseEventExt, ContextMenu } from "@litegraph-ts/core";
import { BuiltInSlotShape, LGraphCanvas, LGraphNode, LLink, LiteGraph, NodeMode, Subgraph, TitleMode, type ContextMenuItem, type IContextMenuItem, type NodeID, type Vector2, type Vector4, type MouseEventExt, ContextMenu, type SerializedLGraphNode } from "@litegraph-ts/core";
import { get, type Unsubscriber } from "svelte/store";
import type ComfyGraph from "./ComfyGraph";
import type ComfyApp from "./components/ComfyApp";
import { ComfyReroute } from "./nodes";
import layoutStates from "./stores/layoutStates";
import layoutStates, { type ContainerLayout } from "./stores/layoutStates";
import queueState from "./stores/queueState";
import selectionState from "./stores/selectionState";
import { createTemplate, type ComfyBoxTemplate, serializeTemplate } from "./ComfyBoxTemplate";
import templateState from "./stores/templateState";
import { createTemplate, type ComfyBoxTemplate, serializeTemplate, type SerializedComfyBoxTemplate } from "./ComfyBoxTemplate";
import notify from "./notify";
import { calcNodesBoundingBox } from "./utils";
export type SerializedGraphCanvasState = {
offset: Vector2,
@@ -17,6 +19,7 @@ export type SerializedGraphCanvasState = {
export default class ComfyGraphCanvas extends LGraphCanvas {
app: ComfyApp | null;
private _unsubscribe: Unsubscriber;
isExportingSVG: boolean = false;
get comfyGraph(): ComfyGraph | null {
return this.graph as ComfyGraph;
@@ -439,19 +442,35 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
this.graph.add(subgraph)
}
override getCanvasMenuOptions(): ContextMenuItem[] {
const options = super.getCanvasMenuOptions();
/*
* Inserts a ComfyBox template. Logic is similar to pasting from the
* clipboard in vanilla litegraph.
*/
insertTemplate(template: SerializedComfyBoxTemplate, pos: Vector2, container: ContainerLayout, containerIndex: number): [LGraphNode[], IDragItem] {
const comfyGraph = this.graph as ComfyGraph;
options.push(
{
content: "Convert to Subgraph",
has_submenu: false,
disabled: Object.keys(this.selected_nodes).length === 0,
callback: this.convertToSubgraph.bind(this)
},
)
let [min_x, min_y, max_x, max_y] = calcNodesBoundingBox(template.nodes);
return options
const width = max_x - min_x
const height = max_y - min_y
pos[0] -= width / 2
pos[1] -= height / 2
const layout = comfyGraph.layout;
if (layout == null) {
console.error("[ComfyGraphCanvas] graph has no layout!", comfyGraph)
return;
}
// The following operations modify the template in-place, so be sure it's been cloned first
const cloned = LiteGraph.cloneObject(template)
const nodeMapping = comfyGraph.insertTemplate(cloned, pos);
const templateLayoutRoot = layout.insertTemplate(cloned, comfyGraph, nodeMapping, container, containerIndex);
this.selectNodes(Object.values(nodeMapping).filter(n => n.graph === this.graph));
return [Object.values(nodeMapping), templateLayoutRoot]
}
saveAsTemplate(_value: IContextMenuItem, _options, mouseEvent, prevMenu, node?: LGraphNode) {
@@ -467,9 +486,35 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
const template = result as ComfyBoxTemplate;
console.warn("TEMPLATEFOUND", template)
const serialized = serializeTemplate(this, template);
try {
if (templateState.add(serialized)) {
notify("Template saved!", { type: "success" })
}
else {
notify("Failed to save template: already exists in LocalStorage", { type: "error" })
}
}
catch (error) {
// Quota exceeded?
notify(`Failed to save template: ${error}`, { type: "error", timeout: 10000 })
}
}
override getCanvasMenuOptions(): ContextMenuItem[] {
const options = super.getCanvasMenuOptions();
options.push(
{
content: "Convert to Subgraph",
has_submenu: false,
disabled: Object.keys(this.selected_nodes).length === 0,
callback: this.convertToSubgraph.bind(this)
},
)
return options
}
override getNodeMenuOptions(node: LGraphNode): ContextMenuItem[] {
@@ -510,4 +555,17 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
return options
}
override onRenderBackground(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): boolean {
if (this.isExportingSVG) {
ctx.clearRect(
this.visible_area[0],
this.visible_area[1],
this.visible_area[2],
this.visible_area[3]
);
return true;
}
return false;
}
}

View File

@@ -1,8 +1,8 @@
import { parse } from 'csv-parse/browser/esm/sync';
import { timeExecutionMs } from './utils';
import { insertCompletionText, type Completion, type CompletionContext, type CompletionResult, type CompletionSource, type CompletionConfig, autocompletion } from '@codemirror/autocomplete';
import { insertCompletionText, type Completion, type CompletionContext, type CompletionResult, type CompletionSource, autocompletion } from '@codemirror/autocomplete';
import { syntaxTree } from '@codemirror/language';
import type { Extension, TransactionSpec } from '@codemirror/state';
import type { Extension } from '@codemirror/state';
import type { EditorView } from '@codemirror/view';
import type { StyleSpec } from "style-mod"
@@ -43,7 +43,7 @@ export const TAG_CATEGORY_COLORS: StyleSpec = Object.values(TAG_CATEGORY_DATA)
[`.cm-autocompletion-${d.name}`, { color: d.color + " !important" }],
]
})
.reduce((dict, el) => (dict[el[0]] = el[1], dict), {})
.reduce((dict: StyleSpec, el: [string, any]) => (dict[el[0]] = el[1], dict), {})
export type DanbooruTag = {
text: string,
@@ -116,7 +116,6 @@ export default class DanbooruTags {
})
console.log(`Parsed ${this.tags.length} tags in ${time / 1000}ms.`)
console.error(this.tags[0])
}
autocomplete(context: CompletionContext): CompletionResult {

View File

@@ -15,6 +15,7 @@
import { startDrag, stopDrag } from "$lib/utils"
import { writable, type Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils";
import { handleContainerConsider, handleContainerFinalize } from "./utils";
export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null;
@@ -46,14 +47,12 @@
isOpen = container.isOpen
}
function handleConsider(evt: any) {
children = layoutState.updateChildren(container, evt.detail.items)
// console.log(dragItems);
function handleConsider(evt: CustomEvent<DndEvent<IDragItem>>) {
children = handleContainerConsider(layoutState, container, evt)
};
function handleFinalize(evt: any) {
children = layoutState.updateChildren(container, evt.detail.items)
// Ensure dragging is stopped on drag finish
function handleFinalize(evt: CustomEvent<DndEvent<IDragItem>>) {
children = handleContainerFinalize(layoutState, container, evt)
};
function handleClick(e: CustomEvent<boolean>) {
@@ -85,6 +84,7 @@
class:empty={children.length === 0}
class:edit={edit}
use:dndzone="{{
type: "layout",
items: children,
flipDurationMs,
centreDraggedOnCursor: true,
@@ -219,8 +219,7 @@
.animation-wrapper {
position: relative;
flex-grow: 100;
flex-basis: 0;
flex: 1 1 0%;
}
.handle-widget:hover {

View File

@@ -7,13 +7,13 @@
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import {fade} from 'svelte/transition';
// 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 { 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";
import { handleContainerConsider, handleContainerFinalize } from "./utils";
export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null;
@@ -45,14 +45,12 @@
attrsChanged = null
}
function handleConsider(evt: any) {
children = layoutState.updateChildren(container, evt.detail.items)
// console.log(dragItems);
function handleConsider(evt: CustomEvent<DndEvent<IDragItem>>) {
children = handleContainerConsider(layoutState, container, evt);
};
function handleFinalize(evt: any) {
children = layoutState.updateChildren(container, evt.detail.items)
// Ensure dragging is stopped on drag finish
function handleFinalize(evt: CustomEvent<DndEvent<IDragItem>>) {
children = handleContainerFinalize(layoutState, container, evt);
};
function _startDrag(e: MouseEvent | TouchEvent) {
@@ -83,6 +81,7 @@
class:empty={children.length === 0}
class:edit
use:dndzone="{{
type: "layout",
items: children,
flipDurationMs,
centreDraggedOnCursor: true,
@@ -261,7 +260,9 @@
.animation-wrapper {
position: relative;
flex-basis: 0%;
flex-grow: 100;
flex-shrink: 100;
}
.handle-hidden {

View File

@@ -10,7 +10,7 @@
import Sidebar from "./Sidebar.svelte";
import SidebarItem from "./SidebarItem.svelte";
import notify from "$lib/notify";
import ComfyWorkflowsView from "./ComfyWorkflowsView.svelte";
import ComfyBoxWorkflowsView from "./ComfyBoxWorkflowsView.svelte";
import GlobalModal from "./GlobalModal.svelte";
export let app: ComfyApp = undefined;
@@ -60,7 +60,7 @@
<div id="container">
<Sidebar selected="generate">
<SidebarItem id="generate" name="Generate" icon={Image}>
<ComfyWorkflowsView {app} {uiTheme} />
<ComfyBoxWorkflowsView {app} {uiTheme} />
</SidebarItem>
<SidebarItem id="settings" name="Settings" icon={Gear}>
</SidebarItem>

View File

@@ -5,6 +5,7 @@ import A1111PromptModal from "./modal/A1111PromptModal.svelte";
import ConfirmConvertWithMissingNodeTypesModal from "./modal/ConfirmConvertWithMissingNodeTypesModal.svelte";
import MissingNodeTypesModal from "./modal/MissingNodeTypesModal.svelte";
import WorkflowLoadErrorModal from "./modal/WorkflowLoadErrorModal.svelte";
import EditTemplateModal from "./modal/EditTemplateModal.svelte";
import * as nodes from "$lib/nodes/index";
@@ -21,13 +22,13 @@ import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import { ComfyComboNode } from "$lib/nodes/widgets";
import notify from "$lib/notify";
import parseA1111, { type A1111ParsedInfotext } from "$lib/parseA1111";
import configState from "$lib/stores/configState";
import layoutStates, { defaultWorkflowAttributes, type SerializedLayoutState } from "$lib/stores/layoutStates";
import configState, { type ConfigState } from "$lib/stores/configState";
import layoutStates, { defaultWorkflowAttributes, isComfyWidgetNode, type SerializedLayoutState } from "$lib/stores/layoutStates";
import modalState from "$lib/stores/modalState";
import queueState from "$lib/stores/queueState";
import selectionState from "$lib/stores/selectionState";
import uiState from "$lib/stores/uiState";
import workflowState, { ComfyWorkflow, type WorkflowAttributes, type WorkflowInstID } from "$lib/stores/workflowState";
import workflowState, { ComfyBoxWorkflow, type WorkflowAttributes, type WorkflowInstID } from "$lib/stores/workflowState";
import type { SerializedPromptOutput } from "$lib/utils";
import { basename, capitalize, download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range } from "$lib/utils";
import { tick } from "svelte";
@@ -35,6 +36,8 @@ import { type SvelteComponentDev } from "svelte/internal";
import { get, writable, type Writable } from "svelte/store";
import ComfyPromptSerializer, { isActiveBackendNode, UpstreamNodeLocator } from "./ComfyPromptSerializer";
import DanbooruTags from "$lib/DanbooruTags";
import { deserializeTemplateFromSVG } from "$lib/ComfyBoxTemplate";
import templateState from "$lib/stores/templateState";
export const COMFYBOX_SERIAL_VERSION = 1;
@@ -55,7 +58,7 @@ export type OpenWorkflowOptions = {
type PromptQueueItem = {
num: number,
batchCount: number
workflow: ComfyWorkflow
workflow: ComfyBoxWorkflow
}
export type A1111PromptAndInfo = {
@@ -189,6 +192,11 @@ export default class ComfyApp {
return;
}
await this.loadConfig();
this.api.hostname = get(configState).comfyUIHostname
this.api.port = get(configState).comfyUIPort
this.setupColorScheme()
this.rootEl = document.getElementById("app-root") as HTMLDivElement;
@@ -220,7 +228,7 @@ export default class ComfyApp {
setActive: false
}
await this.initDefaultWorkflow("defaultWorkflow", options);
await this.initDefaultWorkflow("upscale", options);
await this.initDefaultWorkflow("upscaleByModel", options);
await this.initDefaultWorkflow("conditioningRegions", options);
}
@@ -232,6 +240,7 @@ export default class ComfyApp {
this.addKeyboardHandler();
await this.updateHistoryAndQueue();
templateState.load();
await this.initFrontendFeatures();
@@ -248,7 +257,24 @@ export default class ComfyApp {
return Promise.resolve();
}
/*
* TODO
*/
async loadConfig() {
try {
const config = await fetch(`/config.json`);
const state = await config.json() as ConfigState;
configState.set(state);
}
catch (error) {
console.error(`Failed to load config`, error)
}
}
resizeCanvas() {
if (!this.canvasEl)
return;
this.canvasEl.width = this.canvasEl.parentElement.offsetWidth;
this.canvasEl.height = this.canvasEl.parentElement.offsetHeight;
this.canvasEl.style.width = ""
@@ -256,7 +282,7 @@ export default class ComfyApp {
this.lCanvas.draw(true, true);
}
serialize(workflow: ComfyWorkflow, canvas?: SerializedGraphCanvasState): SerializedAppState {
serialize(workflow: ComfyBoxWorkflow, canvas?: SerializedGraphCanvasState): SerializedAppState {
const layoutState = layoutStates.getLayout(workflow.id);
if (layoutState == null)
throw new Error("Workflow has no layout!")
@@ -278,7 +304,7 @@ export default class ComfyApp {
saveStateToLocalStorage() {
try {
uiState.update(s => { s.isSavingToLocalStorage = true; return s; })
uiState.update(s => { s.forceSaveUserState = true; return s; })
const state = get(workflowState)
const workflows = state.openedWorkflows
const savedWorkflows = workflows.map(w => this.serialize(w));
@@ -294,7 +320,7 @@ export default class ComfyApp {
notify(`Failed saving to local storage:\n${err}`, { type: "error" })
}
finally {
uiState.update(s => { s.isSavingToLocalStorage = false; return s; })
uiState.update(s => { s.forceSaveUserState = null; return s; })
}
}
@@ -459,12 +485,16 @@ export default class ComfyApp {
} catch (error) { }
}
if (workflow && typeof workflow.createdBy === "string") {
this.openWorkflow(workflow);
}
else {
// TODO handle vanilla workflows
throw new Error("Workflow was not in ComfyBox format!")
if (workflow == null)
return;
if (typeof workflow === "object") {
if (typeof workflow.createdBy === "string")
this.openWorkflow(workflow);
else {
// TODO handle vanilla workflows
throw new Error("Workflow was not in ComfyBox format!")
}
}
});
}
@@ -631,7 +661,7 @@ export default class ComfyApp {
refreshCombos: true,
warnMissingNodeTypes: true
}
): Promise<ComfyWorkflow> {
): Promise<ComfyBoxWorkflow> {
if (data.version !== COMFYBOX_SERIAL_VERSION) {
const mes = `Invalid ComfyBox saved data format: ${data.version} `
notify(mes, { type: "error" })
@@ -640,7 +670,7 @@ export default class ComfyApp {
this.clean();
let workflow: ComfyWorkflow;
let workflow: ComfyBoxWorkflow;
try {
workflow = workflowState.openWorkflow(this.lCanvas, data, options.setActive);
}
@@ -761,6 +791,30 @@ export default class ComfyApp {
await this.openWorkflow(state, options)
}
saveWorkflowStateAsDefault(workflow: ComfyBoxWorkflow | null) {
workflow ||= workflowState.getActiveWorkflow();
if (workflow == null)
return;
for (const node of workflow.graph.iterateNodesInOrderRecursive()) {
if (isComfyWidgetNode(node)) {
node.properties.defaultValue = node.getValue();
}
}
}
resetCurrentWorkflow() {
const workflow = workflowState.getActiveWorkflow();
if (workflow == null)
return;
for (const node of workflow.graph.iterateNodesInOrderRecursive()) {
if (isComfyWidgetNode(node)) {
node.setValue(node.properties.defaultValue);
}
}
}
clear() {
this.clean();
@@ -798,6 +852,8 @@ export default class ComfyApp {
return;
}
this.saveWorkflowStateAsDefault(workflow);
const promptFilename = get(configState).promptForWorkflowName;
const title = workflow.attrs.title.trim() || "workflow"
@@ -831,7 +887,7 @@ export default class ComfyApp {
* Converts the current graph workflow for sending to the API
* @returns The workflow and node links
*/
graphToPrompt(workflow: ComfyWorkflow, tag: string | null = null): SerializedPrompt {
graphToPrompt(workflow: ComfyBoxWorkflow, tag: string | null = null): SerializedPrompt {
return this.promptSerializer.serialize(workflow.graph, tag)
}
@@ -853,7 +909,7 @@ export default class ComfyApp {
tag = null;
this.processingQueue = true;
let workflow: ComfyWorkflow;
let workflow: ComfyBoxWorkflow;
try {
while (this.queueItems.length) {
@@ -1001,6 +1057,45 @@ export default class ComfyApp {
}
};
reader.readAsText(file);
} else if (file.type === "image/svg+xml" || file.name.endsWith(".svg")) {
const templateAndSvg = await deserializeTemplateFromSVG(file);
const importTemplate = () => {
try {
if (templateState.add(templateAndSvg)) {
notify("Template imported successfully!", { type: "success" })
}
else {
notify("Template already exists in saved list.", { type: "warning" })
}
}
catch (error) {
notify(`Error importing template: ${error}`, { type: "error", timeout: 10000 })
}
}
modalState.pushModal({
title: "ComfyBox Template Preview",
svelteComponent: EditTemplateModal,
closeOnClick: false,
showCloseButton: false,
svelteProps: {
templateAndSvg,
editable: false
},
buttons: [
{
name: "Import",
variant: "primary",
onClick: importTemplate
},
{
name: "Close",
variant: "secondary",
onClick: () => { }
},
]
})
}
}
@@ -1017,7 +1112,7 @@ export default class ComfyApp {
/**
* Refresh combo list on whole nodes
*/
async refreshComboInNodes(workflow?: ComfyWorkflow, defs?: Record<string, ComfyNodeDef>, flashUI: boolean = false) {
async refreshComboInNodes(workflow?: ComfyBoxWorkflow, defs?: Record<string, ComfyNodeDef>, flashUI: boolean = false) {
workflow ||= workflowState.getActiveWorkflow();
if (workflow == null) {
notify("No active workflow!", { type: "error" })

View File

@@ -8,10 +8,10 @@
import Menu from './menu/Menu.svelte';
import MenuOption from './menu/MenuOption.svelte';
import MenuDivider from './menu/MenuDivider.svelte';
import type { ComfyWorkflow } from "$lib/stores/workflowState";
import type { ComfyBoxWorkflow } from "$lib/stores/workflowState";
export let app: ComfyApp;
export let workflow: ComfyWorkflow;
export let workflow: ComfyBoxWorkflow | null = null;
let layoutState: WritableLayoutStateStore | null;
@@ -111,9 +111,9 @@
let menuPos = { x: 0, y: 0 };
let showMenu = false;
$: $layoutState.isMenuOpen = showMenu;
$: if (layoutState) $layoutState.isMenuOpen = showMenu;
$: if ($layoutState.root) {
$: if (layoutState && $layoutState.root) {
root = $layoutState.root
} else {
root = null;
@@ -138,51 +138,70 @@
}
</script>
{#if layoutState != null}
<div id="comfy-workflow-view" on:contextmenu={onRightClick}>
<WidgetContainer bind:dragItem={root} classes={["root-container"]} {layoutState} />
{#if workflow != null}
{#if layoutState != null}
<div class="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}>
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => moveUp()}
text="Move Up" />
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => moveDown()}
text="Move Down" />
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => sendToTop()}
text="Send to Top" />
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => sendToBottom()}
text="Send to Bottom" />
<MenuDivider/>
<MenuOption
isDisabled={$selectionState.currentSelection.length === 0}
on:click={() => groupWidgets(false)}
text="Group" />
<MenuOption
isDisabled={$selectionState.currentSelection.length === 0}
on:click={() => groupWidgets(true)}
text="Group Horizontally" />
<MenuOption
isDisabled={!canUngroup}
on:click={ungroup}
text={isDeleteGroup ? "Delete Group" : "Ungroup"} />
</Menu>
{/if}
{:else}
<div class="no-workflows">
<span>No workflow loaded</span>
</div>
{/if}
{#if showMenu}
<Menu {...menuPos} on:click={closeMenu} on:clickoutside={closeMenu}>
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => moveUp()}
text="Move Up" />
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => moveDown()}
text="Move Down" />
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => sendToTop()}
text="Send to Top" />
<MenuOption
isDisabled={$selectionState.currentSelection.length !== 1}
on:click={() => sendToBottom()}
text="Send to Bottom" />
<MenuDivider/>
<MenuOption
isDisabled={$selectionState.currentSelection.length === 0}
on:click={() => groupWidgets(false)}
text="Group" />
<MenuOption
isDisabled={$selectionState.currentSelection.length === 0}
on:click={() => groupWidgets(true)}
text="Group Horizontally" />
<MenuOption
isDisabled={!canUngroup}
on:click={ungroup}
text={isDeleteGroup ? "Delete Group" : "Ungroup"} />
</Menu>
{/if}
<style lang="scss">
#comfy-workflow-view {
.comfy-workflow-view {
width: 100%;
height: 100%;
overflow: auto;
}
.no-workflows {
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
display: flex;
position: relative;
color: var(--body-text-color);
> span {
margin: auto;
}
}
</style>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { Button } from "@gradio/button";
import type ComfyApp from "./ComfyApp";
import DropZone from "./DropZone.svelte";
import DropZone from "./DropZone.svelte";
import interfaceState from "$lib/stores/interfaceState";
export let app: ComfyApp;
export let transitioning: boolean = false;
function doRecenter(): void {
app?.lCanvas?.recenter();
@@ -17,7 +17,7 @@
<DropZone {app} />
</div>
<div class="bar">
{#if !transitioning}
{#if !$interfaceState.graphTransitioning}
<span class="left">
<button on:click={doRecenter}>Recenter</button>
</span>

View File

@@ -0,0 +1,108 @@
<script context="module" lang="ts">
export type ComfyPaneMode = "none" | "activeWorkflow" | "graph" | "properties" | "templates" | "queue"
</script>
<script lang="ts">
/*
* A panel/sidebar that can be switched between different modes.
*/
import workflowState from "$lib/stores/workflowState";
import type ComfyApp from "./ComfyApp";
import { Sliders2, BoxSeam, LayoutTextSidebarReverse } from "svelte-bootstrap-icons";
import ComfyBoxWorkflowView from "./ComfyBoxWorkflowView.svelte";
import ComfyGraphView from "./ComfyGraphView.svelte";
import ComfyProperties from "./ComfyProperties.svelte";
import ComfyQueue from "./ComfyQueue.svelte";
import ComfyTemplates from "./ComfyTemplates.svelte";
import { SvelteComponent } from "svelte";
export let app: ComfyApp
export let mode: ComfyPaneMode = "none";
export let showSwitcher: boolean = false;
const MODES: [ComfyPaneMode, typeof SvelteComponent][] = [
["properties", Sliders2],
["templates", BoxSeam],
["queue", LayoutTextSidebarReverse]
]
function switchMode(newMode: ComfyPaneMode) {
console.warn("switch", mode, newMode)
mode = newMode;
}
</script>
<div class="pane">
<div class="pane-wrapper" class:has-switcher={showSwitcher}>
{#if mode === "activeWorkflow"}
<ComfyBoxWorkflowView {app} workflow={$workflowState.activeWorkflow} />
{:else if mode === "graph"}
<ComfyGraphView {app} />
{:else if mode === "properties"}
<ComfyProperties workflow={$workflowState.activeWorkflow} />
{:else if mode === "templates"}
<ComfyTemplates {app} />
{:else if mode === "queue"}
<ComfyQueue {app} />
{:else}
<div class="blank-panel">(Blank)</div>
{/if}
</div>
{#if showSwitcher}
<div class="switcher">
{#each MODES as [theMode, icon]}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<button class="mode-button ternary"
disabled={mode === theMode}
class:selected={mode === theMode}
on:click={() => switchMode(theMode)}>
<svelte:component this={icon} width="100%" height="100%" />
</button>
{/each}
</div>
{/if}
</div>
<style lang="scss">
$button-height: 2.5rem;
.pane {
width: 100%;
height: 100%;
.pane-wrapper {
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
&.has-switcher {
height: calc(100% - $button-height);
}
}
.switcher {
height: $button-height;
display: flex;
flex-direction: row;
color: var(--comfy-accent-soft);
.mode-button {
height: 100%;
width: 100%;
padding: 0.5rem;
@include square-button;
&:hover {
color: var(--body-text-color);
}
&.selected {
color: var(--body-text-color);
background-color: var(--panel-background-fill);
}
}
}
}
</style>

View File

@@ -11,9 +11,9 @@
import ComfyNumberProperty from "./ComfyNumberProperty.svelte";
import ComfyComboProperty from "./ComfyComboProperty.svelte";
import type { ComfyWidgetNode } from "$lib/nodes/widgets";
import type { ComfyWorkflow } from "$lib/stores/workflowState";
import type { ComfyBoxWorkflow } from "$lib/stores/workflowState";
export let workflow: ComfyWorkflow | null;
export let workflow: ComfyBoxWorkflow | null;
let layoutState: WritableLayoutStateStore | null = null

View File

@@ -211,7 +211,7 @@
await tick(); // Wait for list size to be recalculated
queueList.scroll({ top: queueList.scrollHeight })
}
console.warn("[ComfyQueue] BUILDQUEUE", _entries, $queuePending, $queueRunning)
console.warn("[ComfyQueue] BUILDQUEUE", _entries.length, $queuePending.length, $queueRunning.length)
}
async function updateFromHistory() {
@@ -219,7 +219,7 @@
if (queueList) {
queueList.scrollTo(0, 0);
}
console.warn("[ComfyQueue] BUILDHISTORY", _entries, $queueCompleted)
console.warn("[ComfyQueue] BUILDHISTORY", _entries.length, $queueCompleted.length)
}
async function interrupt() {
@@ -354,10 +354,11 @@
<style lang="scss">
$pending-height: 200px;
$display-mode-buttons-height: 2rem;
$pane-mode-buttons-height: 2.5rem;
$bottom-bar-height: 70px;
$workflow-tabs-height: 2.5rem;
$mode-buttons-height: 30px;
$queue-height: calc(100vh - #{$pending-height} - #{$mode-buttons-height} - #{$bottom-bar-height} - #{$workflow-tabs-height} - 0.9rem);
$queue-height: calc(100vh - #{$pending-height} - #{$pane-mode-buttons-height} - #{$mode-buttons-height} - #{$bottom-bar-height} - #{$workflow-tabs-height} - 0.9rem);
$queue-height-history: calc(#{$queue-height} - #{$display-mode-buttons-height});
.prompt-modal-header {

View File

@@ -0,0 +1,280 @@
<script lang="ts">
import { embedTemplateInSvg, type SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate";
import templateState from "$lib/stores/templateState";
import uiState from "$lib/stores/uiState";
import { download, truncateString } from "$lib/utils";
import type ComfyApp from "./ComfyApp";
import { flip } from 'svelte/animate';
import {fade} from 'svelte/transition';
import {cubicIn} from 'svelte/easing';
import { dndzone, TRIGGERS, SHADOW_PLACEHOLDER_ITEM_ID, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import { defaultWidgetAttributes, type TemplateLayout } from "$lib/stores/layoutStates";
import { v4 as uuidv4 } from "uuid"
import { get, writable } from "svelte/store";
import EditTemplateModal from "./modal/EditTemplateModal.svelte";
import modalState, { type ModalData } from "$lib/stores/modalState";
import notify from "$lib/notify";
export let app: ComfyApp
type DNDConsiderOrFinalizeEvent<T> = {
items: T[],
info: any,
el: Node,
id: string,
trigger?: string,
source?: string
}
let _sorted: TemplateLayout[] = []
$: rebuildTemplates($templateState.templates);
function rebuildTemplates(templates: SerializedComfyBoxTemplate[]) {
_sorted = Array.from(templates).map(t => {
return {
type: "template", id: uuidv4(), template: t, attrs: {...defaultWidgetAttributes}, attrsChanged: writable(0)
}
});
_sorted.sort(t => t.template.metadata.createdAt || 0);
}
const flipDurationMs = 200;
let shouldIgnoreDndEvents = false;
function handleDndConsider(e: CustomEvent<DNDConsiderOrFinalizeEvent<TemplateLayout>>) {
// console.warn(`got consider ${JSON.stringify(e.detail, null, 2)}`);
const {trigger, id} = e.detail.info;
if (trigger === TRIGGERS.DRAG_STARTED) {
// console.warn(`copying ${id}`);
const idx = _sorted.findIndex(item => item.id === id);
const newId = `${id}_copy_${Math.round(Math.random()*1000000)}`;
e.detail.items = e.detail.items.filter(item => !item[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
e.detail.items.splice(idx, 0, {..._sorted[idx], id: newId});
_sorted = e.detail.items;
shouldIgnoreDndEvents = true;
}
else if (!shouldIgnoreDndEvents) {
_sorted = e.detail.items;
}
else {
_sorted = _sorted;
}
}
function handleDndFinalize(e: CustomEvent<DNDConsiderOrFinalizeEvent<TemplateLayout>>) {
if (!shouldIgnoreDndEvents) {
_sorted = e.detail.items;
}
else {
_sorted = _sorted;
shouldIgnoreDndEvents = false;
}
}
function handleClick(layout: TemplateLayout) {
if ($uiState.uiUnlocked)
return;
const updateTemplate = (modal: ModalData) => {
const state = get(modal.state);
layout.template.metadata.title = state.name || layout.template.metadata.title
layout.template.metadata.author = state.author || layout.template.metadata.author
layout.template.metadata.description = state.description || layout.template.metadata.description
}
const saveTemplate = (modal: ModalData) => {
updateTemplate(modal);
try {
templateState.update(layout.template);
notify("Saved template!", { type: "success" })
}
catch (error) {
notify(`Failed to save template: ${error}`, { type: "error", timeout: 10000 })
}
}
const deleteTemplate = (modal: ModalData) => {
if (!confirm("Are you sure you want to delete this template?"))
return false;
try {
if (templateState.remove(layout.template.id)) {
notify("Template deleted!", { type: "success" })
}
else {
notify("Failed to delete template: not saved to local storage.", { type: "warning" })
}
}
catch (error) {
notify(`Failed to delete template: ${error}`, { type: "error", timeout: 10000 })
}
}
const downloadTemplate = (modal: ModalData) => {
updateTemplate(modal);
const svg = embedTemplateInSvg(layout.template);
const title = layout.template.metadata.title || "template";
download(`${title}.svg`, svg, "image/svg+xml");
}
modalState.pushModal({
svelteComponent: EditTemplateModal,
svelteProps: {
templateAndSvg: layout.template
},
showCloseButton: false,
closeOnClick: false,
buttons: [
{
name: "Save",
variant: "primary",
onClick: saveTemplate
},
{
name: "Download",
variant: "secondary",
onClick: downloadTemplate,
closeOnClick: false
},
{
name: "Delete",
variant: "secondary",
onClick: deleteTemplate
},
{
name: "Close",
variant: "secondary",
onClick: () => {
}
},
]
})
}
</script>
<div class="template-list">
<div class="template-entries">
{#if _sorted.length > 0}
{@const draggable = $uiState.uiUnlocked}
<div class="template-category-group">
<div class="template-category-header">
General
</div>
<div class="template-entries-wrapper"
use:dndzone={{
type: "layout",
items: _sorted,
flipDurationMs,
dragDisabled: !draggable,
dropFromOthersDisabled: true
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}>
{#each _sorted.filter(i => i.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item(item.id)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="template-entry" class:draggable on:click={() => handleClick(item)}>
<div class="template-name">{item.template.metadata.title}</div>
<div class="template-desc">{item.template.metadata.description}</div>
</div>
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='template-drag-item-shadow'/>
{/if}
{/each}
</div>
</div>
{:else}
<div class="no-templates">
<span>(No templates)</span>
</div>
{/if}
</div>
</div>
<style lang="scss">
.template-list {
height: 100%;
}
.template-entries {
height: 100%;
overflow-y: auto;
display: flex;
flex-flow: column nowrap;
}
.template-category-header {
color: var(--body-text-color);
background: var(--block-background-fill);
border-color: var(--panel-border-color);
padding: 0.8rem 1.0rem;
font-weight: bold;
}
.template-entry {
padding: 1.0rem;
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--block-border-color);
border-top: 1px solid var(--table-border-color);
color: var(--body-text-color);
background: var(--panel-background-fill);
max-height: 14rem;
position: relative;
user-select: none;
text-overflow: ellipsis;
overflow: hidden;
font-size: 13pt;
.template-desc {
opacity: 65%;
font-size: 11pt;
}
&.draggable {
border: 5px dashed var(--secondary-500);
margin: 0.2em;
}
&:hover:not(:has(img:hover)):not(:has(button:hover)) {
cursor: pointer;
background: var(--block-background-fill);
&.draggable {
cursor: grab;
background: var(--secondary-700);
}
&.running {
background: var(--comfy-accent-soft);
}
}
}
.no-templates {
display: flex;
color: var(--comfy-accent-soft);
flex-direction: row;
margin: auto;
height: 100%;
color: var(--comfy-accent-soft);
span {
margin: auto;
font-size: 32px;
font-weight: bolder;
}
}
.template-drag-item-shadow {
position: absolute;
top: 0; left:0; right: 0; bottom: 0;
visibility: visible;
border: 1px dashed grey;
background: lightblue;
opacity: 0.5;
margin: 0;
}
</style>

View File

@@ -13,7 +13,8 @@
}
function onButtonClicked(modal: ModalData, button: ModalButton, closeDialog: Function) {
button.onClick(modal);
if (button.onClick(modal) === false)
return
if (button.closeOnClick !== false) {
closeDialog()
@@ -29,9 +30,11 @@
{/if}
</div>
<svelte:fragment>
{#if modal != null && modal.svelteComponent != null}
<svelte:component this={modal.svelteComponent} {...modal.svelteProps} _modal={modal}/>
{/if}
<div class="modal-body">
{#if modal != null && modal.svelteComponent != null}
<svelte:component this={modal.svelteComponent} {...modal.svelteProps} _modal={modal}/>
{/if}
</div>
</svelte:fragment>
<div slot="buttons" class="buttons" let:closeDialog>
{#if modal != null && modal.buttons?.length > 0}
@@ -52,6 +55,12 @@
<style lang="scss">
.buttons {
gap: var(--spacing-sm);
display: flex;
flex-direction: row;
gap: var(--spacing-md);
}
.modal-body {
overflow: auto;
}
</style>

View File

@@ -2,6 +2,7 @@
import UploadText from "$lib/components/gradio/app/UploadText.svelte";
import type { ComfyImageLocation } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify";
import configState from "$lib/stores/configState";
import { convertComfyOutputEntryToGradio, convertComfyOutputToComfyURL, type ComfyUploadImageAPIResponse } from "$lib/utils";
import { Block, BlockLabel } from "@gradio/atoms";
import { File as FileIcon } from "@gradio/icons";
@@ -68,7 +69,7 @@
dispatch("uploading")
const url = `http://${location.hostname}:8188` // TODO make configurable
const url = configState.getBackendURL();
const requests = files.map(async (file) => {
const formData = new FormData();
@@ -174,8 +175,6 @@
uploaded = true;
}
$: console.warn(imgWidth, imgHeight, "IMGSIZE!!")
function handle_clear(_e: CustomEvent<null>) {
_value = null;
value = [];

View File

@@ -94,9 +94,6 @@
display: flex;
flex-direction: row;
padding-top: 0.5em;
}
.button-row, .buttons {
gap: var(--spacing-sm);
gap: var(--spacing-md);
}
</style>

View File

@@ -10,7 +10,7 @@
import type { Styles } from "@gradio/utils";
import { comfyFileToComfyBoxMetadata, comfyURLToComfyFile, countNewLines } from "$lib/utils";
import ReceiveOutputTargets from "./modal/ReceiveOutputTargets.svelte";
import workflowState, { type ComfyWorkflow, type WorkflowReceiveOutputTargets } from "$lib/stores/workflowState";
import workflowState, { type ComfyBoxWorkflow, type WorkflowReceiveOutputTargets } from "$lib/stores/workflowState";
import type { ComfyReceiveOutputNode } from "$lib/nodes/actions";
import type ComfyApp from "./ComfyApp";
@@ -100,7 +100,7 @@
// ImageViewer.instance.showLightbox(e.detail)
}
function sendOutput(workflow: ComfyWorkflow, targetNode: ComfyReceiveOutputNode) {
function sendOutput(workflow: ComfyBoxWorkflow, targetNode: ComfyReceiveOutputNode) {
if (workflow == null || targetNode == null)
return

View File

@@ -15,6 +15,7 @@
import { startDrag, stopDrag } from "$lib/utils"
import type { Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils";
import { handleContainerConsider, handleContainerFinalize } from "./utils";
export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null;
@@ -38,14 +39,12 @@
// attrsChanged = writable(0)
}
function handleConsider(evt: any) {
children = layoutState.updateChildren(container, evt.detail.items)
// console.log(dragItems);
function handleConsider(evt: CustomEvent<DndEvent<IDragItem>>) {
children = handleContainerConsider(layoutState, container, evt)
};
function handleFinalize(evt: any) {
children = layoutState.updateChildren(container, evt.detail.items)
// Ensure dragging is stopped on drag finish
function handleFinalize(evt: CustomEvent<DndEvent<IDragItem>>) {
children = handleContainerFinalize(layoutState, container, evt)
};
function getTabName(container: ContainerLayout, i: number): string {
@@ -89,6 +88,7 @@
class:empty={children.length === 0}
class:edit={edit}
use:dndzone="{{
type: "layout",
items: children,
flipDurationMs,
centreDraggedOnCursor: true,
@@ -222,8 +222,7 @@
.animation-wrapper {
position: relative;
flex-grow: 100;
flex-basis: 0;
flex: 1 100 0%;
}
.handle-widget:hover {

View File

@@ -1,35 +1,73 @@
<script>
import { setContext, createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import { key } from './menu.ts';
<script lang="ts">
import { setContext, createEventDispatcher } from 'svelte';
import { key } from './menu.ts';
export let x;
export let y;
import { offset, flip, shift } from "svelte-floating-ui/dom";
import { createFloatingActions, type ClientRectObject, type VirtualElement } from "svelte-floating-ui";
import { writable, type Writable } from 'svelte/store';
// whenever x and y is changed, restrict box to be within bounds
$: (() => {
if (!menuEl) return;
const [floatingRef, floatingContent] = createFloatingActions({
placement: "right-start",
strategy: "fixed",
middleware: [
offset({ mainAxis: 5, alignmentAxis: 4 }),
flip({
fallbackPlacements: ["left-start"]
}),
shift({ padding: 10 })
],
});
const rect = menuEl.getBoundingClientRect();
x = Math.min(window.innerWidth - rect.width, x);
if (y > window.innerHeight - rect.height) y -= rect.height;
})(x, y);
const dispatch = createEventDispatcher();
export let x;
export let y;
setContext(key, {
dispatchClick: () => dispatch('click')
});
// whenever x and y is changed, restrict box to be within bounds
$: (() => {
if (!menuEl) return;
let menuEl;
function onPageClick(e) {
if (e.target === menuEl || menuEl.contains(e.target)) return;
dispatch('clickoutside');
}
const rect = menuEl.getBoundingClientRect();
x = Math.min(window.innerWidth - rect.width, x);
if (y > window.innerHeight - rect.height) y -= rect.height;
})();
const dispatch = createEventDispatcher();
setContext(key, {
dispatchClick: () => dispatch('click')
});
let menuEl;
function onPageClick(e) {
if (e.target === menuEl || menuEl.contains(e.target)) return;
dispatch('clickoutside');
}
let getBoundingClientRect: () => ClientRectObject;
$: getBoundingClientRect = (): ClientRectObject => {
return {
x,
y,
top: y,
left: x,
bottom: y,
right: x,
width: 0,
height: 0
}
}
const virtualElement: Writable<VirtualElement> = writable({ getBoundingClientRect })
$: virtualElement.set({ getBoundingClientRect })
floatingRef(virtualElement)
</script>
<svelte:body on:click={onPageClick} />
<div class="menu" bind:this={menuEl} style="top: {y}px; left: {x}px;">
<div class="menu" bind:this={menuEl} style="top: {y}px; left: {x}px;" use:floatingContent>
<slot />
</div>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import type { ComfyBoxTemplate, SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate";
import type { SerializedDragEntry, SerializedLayoutState } from "$lib/stores/layoutStates";
import { Block, BlockTitle } from "@gradio/atoms";
import SerializedLayoutPreviewNode from "./SerializedLayoutPreviewNode.svelte";
import Row from "../gradio/app/Row.svelte";
import createDOMPurify from "dompurify"
import Column from "../gradio/app/Column.svelte";
import Accordion from "../gradio/app/Accordion.svelte";
import Textbox from "@gradio/form/src/Textbox.svelte";
import type { ModalData } from "$lib/stores/modalState";
import { writable, type Writable } from "svelte/store";
const DOMPurify = createDOMPurify(window);
export let templateAndSvg: SerializedComfyBoxTemplate;
export let editable: boolean = true;
export let _modal: ModalData;
let layout: SerializedLayoutState | null
let root: SerializedDragEntry | null
let state: Writable<any> = writable({})
$: {
state = _modal.state;
if (!("name" in $state)) {
$state.name = templateAndSvg.metadata.title;
$state.author = templateAndSvg.metadata.author;
$state.description = templateAndSvg.metadata.description;
}
}
let saneSvg: string = "";
$: saneSvg = templateAndSvg
? DOMPurify.sanitize(templateAndSvg.svg, { USE_PROFILES: { svg: true, svgFilters: true } })
.replace("<svg", "<svg style='background: url(\"image/graph-bg.png\")'")
: "";
$: if (templateAndSvg) {
layout = templateAndSvg.layout;
if (layout) {
root = layout.allItems[layout.root];
}
else {
root = null;
}
}
else {
layout = null;
root = null;
}
</script>
<div class="template-preview">
<Row>
<Column>
<div class="template-metadata">
<Block>
<BlockTitle>Metadata</BlockTitle>
<div>
<Textbox label="Name" disabled={!editable} bind:value={$state.name} lines={1} max_lines={1} />
<Textbox label="Author" disabled={!editable} bind:value={$state.author} lines={1} max_lines={1} />
<Textbox label="Description" disabled={!editable} bind:value={$state.description} lines={5} max_lines={5} />
</div>
</Block>
</div>
</Column>
{#if root}
<Column>
<div class="template-layout-preview">
<Block>
<BlockTitle>Layout</BlockTitle>
<SerializedLayoutPreviewNode {layout} entry={root} entryID={root.dragItem.id} />
</Block>
</div>
</Column>
{/if}
</Row>
<div class="template-graph-preview">
<Block>
<Accordion label="Graph">
<Block>
<div class="template-graph-wrapper">
{@html saneSvg}
</div>
</Block>
</Accordion>
</Block>
</div>
</div>
<style lang="scss">
.template-preview {
width: 70vw;
height: 70vh;
display: flex;
flex-direction: column;
gap: var(--layout-gap);
}
.template-metadata {
position: relative;
flex: 1 1 0%;
:global(> .block) {
background: var(--panel-background-fill);
}
}
.template-layout-preview {
flex-grow: 1;
overflow: auto;
:global(> .block) {
background: var(--panel-background-fill);
}
}
.template-graph-preview {
min-width: 0;
:global(> .block) {
background: var(--panel-background-fill);
}
}
.template-graph-wrapper {
overflow: auto;
margin: auto;
}
</style>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import type { ComfyReceiveOutputNode } from "$lib/nodes/actions";
import type { ComfyWorkflow, WorkflowReceiveOutputTargets } from "$lib/stores/workflowState";
import type { ComfyBoxWorkflow, WorkflowReceiveOutputTargets } from "$lib/stores/workflowState";
import { Block, BlockTitle } from "@gradio/atoms";
import { Button } from "@gradio/button";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher<{
select: { workflow: ComfyWorkflow, targetNode: ComfyReceiveOutputNode };
select: { workflow: ComfyBoxWorkflow, targetNode: ComfyReceiveOutputNode };
}>();
export let receiveTargets: WorkflowReceiveOutputTargets[] = [];
function onSelected( workflow: ComfyWorkflow, targetNode: ComfyReceiveOutputNode ) {
function onSelected( workflow: ComfyBoxWorkflow, targetNode: ComfyReceiveOutputNode ) {
dispatch("select", {
workflow,
targetNode

View File

@@ -1,6 +1,6 @@
<script lang="ts" context="module">
export type SendOutputModalResult = {
workflow?: ComfyWorkflow,
workflow?: ComfyBoxWorkflow,
targetNode?: ComfyReceiveOutputNode,
}
</script>
@@ -11,7 +11,7 @@
import type { SlotType } from "@litegraph-ts/core";
import type { Writable } from "svelte/store";
import { StaticImage } from "$lib/components/gradio/image";
import type { ComfyWorkflow, WorkflowReceiveOutputTargets } from "$lib/stores/workflowState";
import type { ComfyBoxWorkflow, WorkflowReceiveOutputTargets } from "$lib/stores/workflowState";
import { comfyBoxImageToComfyURL } from "$lib/utils";
import { Button } from "@gradio/button";
import type { ComfyReceiveOutputNode } from "$lib/nodes/actions";
@@ -29,7 +29,7 @@
images = [comfyBoxImageToComfyURL(value)];
}
function sendOutput(workflow: ComfyWorkflow, targetNode: ComfyReceiveOutputNode) {
function sendOutput(workflow: ComfyBoxWorkflow, targetNode: ComfyReceiveOutputNode) {
const result: SendOutputModalResult = {
workflow,
targetNode

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { type DragItemID, type SerializedDragEntry, type SerializedLayoutState } from "$lib/stores/layoutStates";
import { Block, BlockTitle } from "@gradio/atoms";
import Accordion from "../gradio/app/Accordion.svelte";
export let layout: SerializedLayoutState
export let entryID: DragItemID
export let entry: SerializedDragEntry
</script>
{#if entry}
{#if entry.dragItem.type === "container"}
<div class="layout-container">
<Block>
<Accordion label={entry.dragItem.attrs.title || "(Container)"} open={false}>
{#each entry.children as childID}
{@const child = layout.allItems[childID]}
<svelte:self {layout} entry={child} entryID={childID} />
{/each}
</Accordion>
</Block>
</div>
{:else}
<div class="layout-widget">
<Block>
<BlockTitle>{entry.dragItem.attrs.title}</BlockTitle>
</Block>
</div>
{/if}
{:else}
<Block>
Missing drag entry! {entryID}
</Block>
{/if}
<style lang="scss">
.layout-container {
:global(> .block) {
background: var(--panel-background-fill);
}
}
.layout-widget {
:global(> .block) {
background: var(--block-background-fill);
}
}
</style>

View File

@@ -0,0 +1,47 @@
import type ComfyGraphCanvas from "$lib/ComfyGraphCanvas";
import { type ContainerLayout, type IDragItem, type TemplateLayout, type WritableLayoutStateStore } from "$lib/stores/layoutStates"
import type { LGraphCanvas, Vector2 } from "@litegraph-ts/core";
import { get } from "svelte/store";
export function handleContainerConsider(layoutState: WritableLayoutStateStore, container: ContainerLayout, evt: CustomEvent<DndEvent<IDragItem>>): IDragItem[] {
return layoutState.updateChildren(container, evt.detail.items)
};
export function handleContainerFinalize(layoutState: WritableLayoutStateStore, container: ContainerLayout, evt: CustomEvent<DndEvent<IDragItem>>): IDragItem[] {
const dnd = evt.detail
const info = dnd.info;
const droppedItem = dnd.items.find(i => i.id === info.id);
const isDroppingTemplate = droppedItem?.type === "template"
if (isDroppingTemplate) {
return doInsertTemplate(layoutState, droppedItem as TemplateLayout, container, dnd.items)
}
else {
return layoutState.updateChildren(container, dnd.items)
}
};
function isComfyGraphCanvas(canvas: LGraphCanvas): canvas is ComfyGraphCanvas {
return "insertTemplate" in canvas;
}
function doInsertTemplate(layoutState: WritableLayoutStateStore, droppedTemplate: TemplateLayout, container: ContainerLayout, items: IDragItem[]): IDragItem[] {
const workflow = layoutState.workflow;
const templateItemIndex = items.findIndex(i => i.id === droppedTemplate.id)
const newChildren = items.filter(i => i.id !== droppedTemplate.id);
const canvas = workflow.canvases["app"]?.canvas
if (canvas == null || !isComfyGraphCanvas(canvas) || canvas.graph !== workflow.graph) {
console.error("Couldn't get main graph canvas!")
return newChildren;
}
layoutState.updateChildren(container, newChildren);
const newPos: Vector2 = [canvas.visible_area[0] + canvas.visible_area[2] / 2, canvas.visible_area[1] + canvas.visible_area[3] / 2]
canvas.insertTemplate(droppedTemplate.template, newPos, container, templateItemIndex);
return get(layoutState).allItems[container.id].children;
}

View File

@@ -1,7 +1,7 @@
import { LGraph, type INodeInputSlot, type SerializedLGraph, type LinkID, type UUID, type NodeID, LiteGraph, BuiltInSlotType, type SerializedLGraphNode, type Vector2, BuiltInSlotShape, type INodeOutputSlot, type SlotType } from "@litegraph-ts/core";
import type { SerializedAppState } from "./components/ComfyApp";
import layoutStates, { defaultWorkflowAttributes, type ContainerLayout, type DragItemID, type SerializedDragEntry, type SerializedLayoutState, type WritableLayoutStateStore } from "./stores/layoutStates";
import { ComfyWorkflow, type WorkflowAttributes } from "./stores/workflowState";
import { ComfyBoxWorkflow, type WorkflowAttributes } from "./stores/workflowState";
import type { SerializedGraphCanvasState } from "./ComfyGraphCanvas";
import ComfyApp from "./components/ComfyApp";
import { iterateNodeDefInputs, type ComfyNodeDefInputType, type ComfyNodeDefInputOptions } from "./ComfyNodeDef";
@@ -346,8 +346,8 @@ function removeSerializedNode(vanillaWorkflow: SerializedLGraph, node: Serialize
* Converts a workflow saved with vanilla ComfyUI into a ComfyBox workflow,
* adding UI nodes for each widget.
*/
export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWorkflow, attrs: WorkflowAttributes): [ComfyWorkflow, WritableLayoutStateStore] {
const [comfyBoxWorkflow, layoutState] = ComfyWorkflow.create();
export default function convertVanillaWorkflow(vanillaWorkflow: ComfyVanillaWorkflow, attrs: WorkflowAttributes): [ComfyBoxWorkflow, WritableLayoutStateStore] {
const [comfyBoxWorkflow, layoutState] = ComfyBoxWorkflow.create();
const { root, left, right } = layoutState.initDefaultLayout();
// TODO will need to convert IDs to UUIDs

View File

@@ -10,7 +10,7 @@ import { get } from "svelte/store";
import configState from "$lib/stores/configState";
import type { WidgetLayout, WritableLayoutStateStore } from "$lib/stores/layoutStates";
import layoutStates from "$lib/stores/layoutStates";
import workflowStateStore, { ComfyWorkflow } from "$lib/stores/workflowState";
import workflowStateStore, { ComfyBoxWorkflow } from "$lib/stores/workflowState";
export type DefaultWidgetSpec = {
defaultWidgetNode: new (name?: string) => ComfyWidgetNode,
@@ -111,7 +111,7 @@ export default class ComfyGraphNode extends LGraphNode {
return layoutStates.getDragItemByNode(this);
}
get workflow(): ComfyWorkflow | null {
get workflow(): ComfyBoxWorkflow | null {
return workflowStateStore.getWorkflowByNode(this);
}
@@ -312,7 +312,13 @@ export default class ComfyGraphNode extends LGraphNode {
}
(o as any).saveUserState = this.saveUserState
if (!this.saveUserState && (!get(uiState).isSavingToLocalStorage || get(configState).alwaysStripUserState)) {
let saveUserState = this.saveUserState || get(configState).alwaysStripUserState;
const forceSaveUserState = get(uiState).forceSaveUserState;
if (forceSaveUserState !== null)
saveUserState = forceSaveUserState;
if (!saveUserState) {
this.stripUserState(o)
console.debug("[ComfyGraphNode] stripUserState", this, o)
}

View File

@@ -162,6 +162,8 @@ export default class ComfyComboNode extends ComfyWidgetNode<string> {
override stripUserState(o: SerializedLGraphNode) {
super.stripUserState(o);
o.properties.values = []
o.properties.defaultValue = null;
(o as any).comfyValue = null
}
}

View File

@@ -297,6 +297,9 @@ export default abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
}
notifyPropsChanged() {
if (!this.layoutState)
return;
const layoutEntry = this.layoutState.findLayoutEntryForNode(this.id as ComfyNodeID)
if (layoutEntry && layoutEntry.parent) {
layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1)
@@ -352,7 +355,6 @@ export default abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
override stripUserState(o: SerializedLGraphNode) {
super.stripUserState(o);
(o as any).comfyValue = this.defaultValue;
o.properties.defaultValue = null;
(o as any).comfyValue = this.properties.defaultValue;
}
}

View File

@@ -3,6 +3,12 @@ import { get, writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
export type ConfigState = {
/** Backend domain for ComfyUI */
comfyUIHostname: string,
/** Backend port for ComfyUI */
comfyUIPort: number,
/** Strip user state even if saving to local storage */
alwaysStripUserState: boolean,
@@ -14,18 +20,27 @@ export type ConfigState = {
}
type ConfigStateOps = {
getBackendURL: () => string
}
export type WritableConfigStateStore = Writable<ConfigState> & ConfigStateOps;
const store: Writable<ConfigState> = writable(
{
comfyUIHostname: "localhost",
comfyUIPort: 8188,
alwaysStripUserState: false,
promptForWorkflowName: false,
confirmWhenUnloadingUnsavedChanges: true
})
function getBackendURL(): string {
const state = get(store);
return `${window.location.protocol}//${state.comfyUIHostname}:${state.comfyUIPort}`
}
const configStateStore: WritableConfigStateStore =
{
...store
...store,
getBackendURL
}
export default configStateStore;

View File

@@ -9,6 +9,8 @@ export type InterfaceState = {
pointerNearLeft: boolean,
showIndicator: boolean,
indicatorValue: any,
graphTransitioning: boolean
}
type InterfaceStateOps = {
@@ -22,6 +24,8 @@ const store: Writable<InterfaceState> = writable(
pointerNearLeft: false,
showIndicator: false,
indicatorValue: null,
graphTransitioning: false
})
const debounceDrag = debounce(() => { store.update(s => { s.showIndicator = false; return s }) }, 1000)

View File

@@ -7,8 +7,9 @@ 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 type { ComfyBoxWorkflow, WorkflowAttributes, WorkflowInstID } from './workflowState';
import workflowState from './workflowState';
import type { SerializedComfyBoxTemplate } from '$lib/ComfyBoxTemplate';
export function isComfyWidgetNode(node: LGraphNode): node is ComfyWidgetNode {
return "svelteComponentType" in node
@@ -524,7 +525,37 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
canShow: (di: IDragItem) => di.type === "container"
},
// Combo
{
name: "defaultValue",
type: "string",
location: "nodeProps",
editable: true,
defaultValue: "A",
validNodeTypes: ["ui/combo"],
},
// Checkbox
{
name: "defaultValue",
type: "boolean",
location: "nodeProps",
editable: true,
defaultValue: true,
validNodeTypes: ["ui/checkbox"],
},
// Range
{
name: "defaultValue",
type: "number",
location: "nodeProps",
editable: true,
defaultValue: 0,
min: -2 ^ 16,
max: 2 ^ 16,
validNodeTypes: ["ui/number"],
},
{
name: "min",
type: "number",
@@ -586,6 +617,14 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
},
// Radio
{
name: "defaultValue",
type: "string",
location: "nodeProps",
editable: true,
defaultValue: "Choice A",
validNodeTypes: ["ui/radio"],
},
{
name: "choices",
type: "string",
@@ -597,6 +636,16 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
deserialize: deserializeStringArray,
},
// Text
{
name: "defaultValue",
type: "string",
location: "nodeProps",
editable: true,
defaultValue: "",
validNodeTypes: ["ui/text"],
},
// Workflow
{
name: "title",
@@ -635,7 +684,7 @@ for (const cat of Object.values(ALL_ATTRIBUTES)) {
export { ALL_ATTRIBUTES };
// TODO Should be nested by category for name uniqueness?
const defaultWidgetAttributes: Attributes = {} as any
export const defaultWidgetAttributes: Attributes = {} as any
export const defaultWorkflowAttributes: WorkflowAttributes = {} as any
for (const cat of Object.values(ALL_ATTRIBUTES)) {
for (const spec of Object.values(cat.specs)) {
@@ -657,7 +706,7 @@ export interface IDragItem {
/*
* Type of the item.
*/
type: "container" | "widget",
type: "container" | "widget" | "template",
/*
* Unique ID of the item.
@@ -707,6 +756,21 @@ export interface WidgetLayout extends IDragItem {
node: ComfyWidgetNode
}
/*
* A template being dragged into the workflow UI.
*
* These will never be saved, instead they will be expanded inside
* updateChildren() and then removed.
*/
export interface TemplateLayout extends IDragItem {
type: "template",
/*
* Template to expand
*/
template: SerializedComfyBoxTemplate
}
export type DefaultLayout = {
root: ContainerLayout,
left: ContainerLayout,
@@ -716,7 +780,7 @@ export type DefaultLayout = {
export type DragItemID = UUID;
type LayoutStateOps = {
workflow: ComfyWorkflow | null,
workflow: ComfyBoxWorkflow | null,
addContainer: (parent: ContainerLayout | null, attrs?: Partial<Attributes>, index?: number) => ContainerLayout,
addWidget: (parent: ContainerLayout, node: ComfyWidgetNode, attrs?: Partial<Attributes>, index?: number) => WidgetLayout,
@@ -724,6 +788,7 @@ type LayoutStateOps = {
updateChildren: (parent: IDragItem, children: IDragItem[]) => IDragItem[],
nodeAdded: (node: LGraphNode, options: LGraphAddNodeOptions) => void,
nodeRemoved: (node: LGraphNode, options: LGraphRemoveNodeOptions) => void,
insertTemplate: (template: SerializedComfyBoxTemplate, graph: LGraph, templateNodeIDToNode: Record<NodeID, LGraphNode>, container: ContainerLayout, childIndex: number) => IDragItem,
moveItem: (target: IDragItem, to: ContainerLayout, index?: number) => void,
groupItems: (dragItemIDs: DragItemID[], attrs?: Partial<Attributes>) => ContainerLayout,
ungroup: (container: ContainerLayout) => void,
@@ -752,13 +817,13 @@ export type SerializedDragEntry = {
export type SerializedDragItem = {
type: string,
id: DragItemID,
nodeId: UUID | null,
nodeId: NodeID | null,
attrs: Attributes
}
export type WritableLayoutStateStore = Writable<LayoutState> & LayoutStateOps;
function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateStore {
function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStateStore {
const store: Writable<LayoutState> = writable({
root: null,
allItems: {},
@@ -874,7 +939,7 @@ function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateSt
if (newChildren)
state.allItems[parent.id].children = newChildren;
for (const child of state.allItems[parent.id].children) {
if (child.id === SHADOW_PLACEHOLDER_ITEM_ID)
if (child.id === SHADOW_PLACEHOLDER_ITEM_ID || child.type === "template")
continue;
state.allItems[child.id].parent = parent;
}
@@ -912,7 +977,11 @@ function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateSt
let attrs: Partial<Attributes> = {}
if (options.addedBy === "moveIntoSubgraph" || options.addedBy === "moveOutOfSubgraph") {
if ((options.addedBy as any) === "template") {
// Template layout will be deserialized shortly
return;
}
else if (options.addedBy === "moveIntoSubgraph" || options.addedBy === "moveOutOfSubgraph") {
// All we need to do is update the nodeID linked to this node.
const item = state.allItemsByNode[options.prevNodeID]
delete state.allItemsByNode[options.prevNodeID]
@@ -984,6 +1053,56 @@ function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateSt
store.set(state)
}
/*
* NOTE: Modifies the template in-place, be sure you cloned it beforehand!
*/
function insertTemplate(template: SerializedComfyBoxTemplate, graph: LGraph, templateNodeIDToNode: Record<NodeID, LGraphNode>, container: ContainerLayout, childIndex: number): IDragItem {
const idMapping: Record<DragItemID, DragItemID> = {};
const getDragItemID = (id: DragItemID): DragItemID => {
idMapping[id] ||= uuidv4();
return idMapping[id];
}
// Ensure all IDs are unique, and rewrite node IDs in widgets to point
// to newly created nodes
for (const [id, entry] of Object.entries(template.layout.allItems)) {
const newId = getDragItemID(id);
template.layout.allItems[newId] = entry;
entry.dragItem.id = newId;
if (entry.parent)
entry.parent = getDragItemID(entry.parent)
entry.children = entry.children.map(getDragItemID);
if (entry.dragItem.type === "widget") {
entry.dragItem.nodeId = templateNodeIDToNode[entry.dragItem.nodeId].id;
}
}
if (template.layout.root) {
template.layout.root = getDragItemID(template.layout.root)
// make sure the new root doesn't have a parent since that parent
// was detached from the serialized layout and won't be found in
// template.layout.allItems
template.layout.allItems[template.layout.root].parent = null;
}
const raw = deserializeRaw(template.layout, graph);
// merge the template's detached layout tree into this layout
store.update(s => {
s.allItems = { ...s.allItems, ...raw.allItems }
s.allItemsByNode = { ...s.allItemsByNode, ...raw.allItemsByNode }
return s;
})
moveItem(raw.root, container, childIndex);
return raw.root
}
function moveItem(target: IDragItem, to: ContainerLayout, index?: number) {
const state = get(store)
const entry = state.allItems[target.id]
@@ -1148,6 +1267,13 @@ function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateSt
const queue = [state.allItems[rootID]]
while (queue.length > 0) {
const entry = queue.shift();
if (entry.dragItem.type === "template") {
// If this happens then there's a bug somewhere
console.error("[layoutState] Found template drag item in current layout, skipping!")
continue;
}
allItems[entry.dragItem.id] = {
dragItem: {
type: entry.dragItem.type,
@@ -1177,6 +1303,13 @@ function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateSt
const allItems: Record<DragItemID, SerializedDragEntry> = {}
for (const pair of Object.entries(state.allItems)) {
const [id, entry] = pair;
if (entry.dragItem.type === "template") {
// If this happens then there's a bug somewhere
console.error("[layoutState] Found template drag item in current layout, skipping!")
continue;
}
allItems[id] = {
dragItem: {
type: entry.dragItem.type,
@@ -1195,12 +1328,19 @@ function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateSt
}
}
function deserialize(data: SerializedLayoutState, graph: LGraph) {
function deserializeRaw(data: SerializedLayoutState, graph: LGraph): LayoutState {
const allItems: Record<DragItemID, DragItemEntry> = {}
const allItemsByNode: Record<number, DragItemEntry> = {}
for (const pair of Object.entries(data.allItems)) {
const [id, entry] = pair;
if (entry.dragItem.type === "template") {
// If this happens then there's a bug somewhere
console.error("[layoutState] Found template drag item in serialized layout, skipping!")
continue;
}
const dragItem: IDragItem = {
type: entry.dragItem.type,
id: entry.dragItem.id,
@@ -1249,6 +1389,12 @@ function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateSt
isConfiguring: false,
}
return state
}
function deserialize(data: SerializedLayoutState, graph: LGraph) {
const state = deserializeRaw(data, graph);
console.debug("[layoutState] deserialize", data, state, defaultWorkflowAttributes)
store.set(state)
@@ -1282,6 +1428,7 @@ function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateSt
updateChildren,
nodeAdded,
nodeRemoved,
insertTemplate,
moveItem,
groupItems,
findLayoutEntryForNode,
@@ -1299,7 +1446,7 @@ function createRaw(workflow: ComfyWorkflow | null = null): WritableLayoutStateSt
return layoutStateStore
}
function create(workflow: ComfyWorkflow): WritableLayoutStateStore {
function create(workflow: ComfyBoxWorkflow): WritableLayoutStateStore {
if (get(layoutStates).all[workflow.id] != null) {
throw new Error(`Layout state already created! ${id}`)
}
@@ -1369,8 +1516,8 @@ export type LayoutStateStores = {
}
export type LayoutStateStoresOps = {
create: (workflow: ComfyWorkflow) => WritableLayoutStateStore,
createRaw: (workflow?: ComfyWorkflow | null) => WritableLayoutStateStore,
create: (workflow: ComfyBoxWorkflow) => WritableLayoutStateStore,
createRaw: (workflow?: ComfyBoxWorkflow | null) => WritableLayoutStateStore,
remove: (workflowID: WorkflowInstID) => void,
getLayout: (workflowID: WorkflowInstID) => WritableLayoutStateStore | null,
getLayoutByGraph: (graph: LGraph) => WritableLayoutStateStore | null,

View File

@@ -7,7 +7,7 @@ export type ModalState = Record<string, any>;
export type ModalButton = {
name: string,
variant: "primary" | "secondary",
onClick: (state: ModalData) => void,
onClick: (state: ModalData) => boolean | void,
closeOnClick?: boolean
}
export interface ModalData {

View File

@@ -0,0 +1,128 @@
import type { SerializedComfyBoxTemplate } from '$lib/ComfyBoxTemplate';
import type { UUID } from '@litegraph-ts/core';
import { get, writable } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store';
import { v4 as uuidv4 } from "uuid";
export type TemplateState = {
templates: SerializedComfyBoxTemplate[]
templatesByID: Record<UUID, SerializedComfyBoxTemplate>
}
type TemplateStateOps = {
save: () => void,
load: () => void,
add: (template: SerializedComfyBoxTemplate) => boolean,
update: (template: SerializedComfyBoxTemplate) => boolean,
remove: (templateID: UUID) => boolean,
}
export type WritableTemplateStateStore = Writable<TemplateState> & TemplateStateOps;
const store: Writable<TemplateState> = writable(
{
templates: [],
templatesByID: {}
})
function add(template: SerializedComfyBoxTemplate): boolean {
const state = get(store);
if (state.templatesByID[template.id]) {
return false;
}
store.update(s => {
s.templates.push(template);
s.templatesByID[template.id] = template;
return s;
})
save();
return true;
}
function remove(templateID: UUID): boolean {
const state = get(store);
if (!state.templatesByID[templateID]) {
return false;
}
store.update(s => {
const index = s.templates.findIndex(t => t.id === templateID)
s.templates.splice(index, 1);
delete s.templatesByID[templateID];
return s;
})
save();
return true;
}
function update(template: SerializedComfyBoxTemplate): boolean {
const state = get(store);
if (!state.templatesByID[template.id]) {
return false;
}
store.update(s => {
const oldId = template.id
const index = s.templates.findIndex(t => t.id === oldId)
s.templates.splice(index, 1);
delete s.templatesByID[oldId];
template.id = uuidv4();
s.templates.push(template);
s.templatesByID[template.id] = template;
return s;
})
save();
return true;
}
function save() {
const json = JSON.stringify(get(store).templates)
localStorage.setItem("templates", json)
store.set(get(store))
}
function load() {
const json = localStorage.getItem("templates")
if (!json) {
console.info("No templates in local storage, creating store")
save();
return;
}
const data = JSON.parse(json) as SerializedComfyBoxTemplate[];
if (Array.isArray(data)) {
const templatesByID: Record<UUID, SerializedComfyBoxTemplate> =
data.map(d => [d.id, d])
.reduce((dict, el: [UUID, SerializedComfyBoxTemplate]) => (dict[el[0]] = el[1], dict), {})
store.set({
templates: data,
templatesByID
})
}
else {
store.set({
templates: [],
templatesByID: {}
})
}
}
const templateStateStore: WritableTemplateStateStore =
{
...store,
add,
remove,
update,
save,
load,
}
export default templateStateStore;

View File

@@ -11,7 +11,7 @@ export type UIState = {
uiEditMode: UIEditMode,
reconnecting: boolean,
isSavingToLocalStorage: boolean
forceSaveUserState: boolean | null
}
type UIStateOps = {
@@ -29,7 +29,7 @@ const store: Writable<UIState> = writable(
uiEditMode: "widgets",
reconnecting: false,
isSavingToLocalStorage: false
forceSaveUserState: null,
})
function reconnecting() {

View File

@@ -56,7 +56,7 @@ export type WorkflowAttributes = {
queuePromptButtonRunWorkflow: boolean,
}
export class ComfyWorkflow {
export class ComfyBoxWorkflow {
/*
* Used for uniquely identifying the instance of the opened workflow in the frontend.
*/
@@ -176,8 +176,8 @@ export class ComfyWorkflow {
* will not. If you change your mind later be sure to call
* layoutStates.remove(workflow.id)!
*/
static create(title: string = "New Workflow"): [ComfyWorkflow, WritableLayoutStateStore] {
const workflow = new ComfyWorkflow(title);
static create(title: string = "New Workflow"): [ComfyBoxWorkflow, WritableLayoutStateStore] {
const workflow = new ComfyBoxWorkflow(title);
const layoutState = layoutStates.create(workflow);
return [workflow, layoutState]
}
@@ -227,29 +227,29 @@ export class ComfyWorkflow {
}
export type WorkflowState = {
openedWorkflows: ComfyWorkflow[],
openedWorkflowsByID: Record<WorkflowInstID, ComfyWorkflow>,
openedWorkflows: ComfyBoxWorkflow[],
openedWorkflowsByID: Record<WorkflowInstID, ComfyBoxWorkflow>,
activeWorkflowID: WorkflowInstID | null,
activeWorkflow: ComfyWorkflow | null,
activeWorkflow: ComfyBoxWorkflow | null,
}
export type WorkflowReceiveOutputTargets = {
workflow: ComfyWorkflow,
workflow: ComfyBoxWorkflow,
targetNodes: ComfyReceiveOutputNode[]
}
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, setActive?: boolean) => ComfyWorkflow,
addWorkflow: (canvas: ComfyGraphCanvas, data: ComfyWorkflow, setActive?: boolean) => void,
getWorkflow: (id: WorkflowInstID) => ComfyBoxWorkflow | null
getWorkflowByGraph: (graph: LGraph) => ComfyBoxWorkflow | null
getWorkflowByNode: (node: LGraphNode) => ComfyBoxWorkflow | null
getWorkflowByNodeID: (id: NodeID) => ComfyBoxWorkflow | null
getActiveWorkflow: () => ComfyBoxWorkflow | null
createNewWorkflow: (canvas: ComfyGraphCanvas, title?: string, setActive?: boolean) => ComfyBoxWorkflow,
openWorkflow: (canvas: ComfyGraphCanvas, data: SerializedAppState, setActive?: boolean) => ComfyBoxWorkflow,
addWorkflow: (canvas: ComfyGraphCanvas, data: ComfyBoxWorkflow, setActive?: boolean) => void,
closeWorkflow: (canvas: ComfyGraphCanvas, index: number) => void,
closeAllWorkflows: (canvas: ComfyGraphCanvas) => void,
setActiveWorkflow: (canvas: ComfyGraphCanvas, index: number | WorkflowInstID) => ComfyWorkflow | null,
setActiveWorkflow: (canvas: ComfyGraphCanvas, index: number | WorkflowInstID) => ComfyBoxWorkflow | null,
findReceiveOutputTargets: (type: SlotType | SlotType[]) => WorkflowReceiveOutputTargets[]
}
@@ -262,36 +262,36 @@ const store: Writable<WorkflowState> = writable(
activeWorkflow: null
})
function getWorkflow(id: WorkflowInstID): ComfyWorkflow | null {
function getWorkflow(id: WorkflowInstID): ComfyBoxWorkflow | null {
return get(store).openedWorkflowsByID[id];
}
function getWorkflowByGraph(graph: LGraph): ComfyWorkflow | null {
function getWorkflowByGraph(graph: LGraph): ComfyBoxWorkflow | null {
const workflowID = (graph.getRootGraph() as ComfyGraph)?.workflowID;
if (workflowID == null)
return null;
return getWorkflow(workflowID);
}
function getWorkflowByNode(node: LGraphNode): ComfyWorkflow | null {
function getWorkflowByNode(node: LGraphNode): ComfyBoxWorkflow | null {
return getWorkflowByGraph(node.graph);
}
function getWorkflowByNodeID(id: NodeID): ComfyWorkflow | null {
function getWorkflowByNodeID(id: NodeID): ComfyBoxWorkflow | null {
return Object.values(get(store).openedWorkflows).find(w => {
return w.graph.getNodeByIdRecursive(id) != null
})
}
function getActiveWorkflow(): ComfyWorkflow | null {
function getActiveWorkflow(): ComfyBoxWorkflow | 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);
function createNewWorkflow(canvas: ComfyGraphCanvas, title: string = "New Workflow", setActive: boolean = false): ComfyBoxWorkflow {
const workflow = new ComfyBoxWorkflow(title);
const layoutState = layoutStates.create(workflow);
layoutState.initDefaultLayout();
@@ -307,8 +307,8 @@ function createNewWorkflow(canvas: ComfyGraphCanvas, title: string = "New Workfl
return workflow;
}
function openWorkflow(canvas: ComfyGraphCanvas, data: SerializedAppState, setActive: boolean = true): ComfyWorkflow {
const [workflow, layoutState] = ComfyWorkflow.create("Workflow")
function openWorkflow(canvas: ComfyGraphCanvas, data: SerializedAppState, setActive: boolean = true): ComfyBoxWorkflow {
const [workflow, layoutState] = ComfyBoxWorkflow.create("Workflow")
workflow.deserialize(layoutState, { graph: data.workflow, layout: data.layout, attrs: data.attrs })
addWorkflow(canvas, workflow, setActive);
@@ -316,7 +316,7 @@ function openWorkflow(canvas: ComfyGraphCanvas, data: SerializedAppState, setAct
return workflow;
}
function addWorkflow(canvas: ComfyGraphCanvas, workflow: ComfyWorkflow, setActive: boolean = true) {
function addWorkflow(canvas: ComfyGraphCanvas, workflow: ComfyBoxWorkflow, setActive: boolean = true) {
const state = get(store);
state.openedWorkflows.push(workflow);
state.openedWorkflowsByID[workflow.id] = workflow;
@@ -354,7 +354,7 @@ function closeAllWorkflows(canvas: ComfyGraphCanvas) {
closeWorkflow(canvas, 0)
}
function setActiveWorkflow(canvas: ComfyGraphCanvas, index: number | WorkflowInstID): ComfyWorkflow | null {
function setActiveWorkflow(canvas: ComfyGraphCanvas, index: number | WorkflowInstID): ComfyBoxWorkflow | null {
const state = get(store);
if (state.openedWorkflows.length === 0) {

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
import "klecks/style/style.scss";
import ImageUpload from "$lib/components/ImageUpload.svelte";
import { uploadImageToComfyUI, type ComfyBoxImageMetadata, comfyFileToComfyBoxMetadata, comfyBoxImageToComfyURL, comfyBoxImageToComfyFile, type ComfyUploadImageType, type ComfyImageLocation } from "$lib/utils";
import configState from "$lib/stores/configState";
import notify from "$lib/notify";
import NumberInput from "$lib/components/NumberInput.svelte";
import type { ComfyImageEditorNode } from "$lib/nodes/widgets";
@@ -102,7 +103,7 @@
showModal = true;
const url = `http://${location.hostname}:8188` // TODO make configurable
const url = configState.getBackendURL();
kl = new Klecks({
embedUrl: url,

View File

@@ -86,7 +86,7 @@ const COLOR_MAP: [string, string][] = [
_node ||= node;
bboxes ||= $nodeValue
console.debug("[MultiRegionWidget] Recreate!", bboxes, imageElem, _node)
// console.debug("[MultiRegionWidget] Recreate!", bboxes, _node)
if (_node != null && imageElem != null && imageContainer != null) {
selectedIndex = clamp(selectedIndex, 0, bboxes.length - 1);

View File

@@ -12,6 +12,7 @@
import { basicSetup } from "./TextWidgetCodeVariant";
import { createEventDispatcher, onMount } from "svelte";
import { TAG_CATEGORY_COLORS } from "$lib/DanbooruTags";
import { Block, BlockTitle } from "@gradio/atoms";
export let widget: WidgetLayout;
export let node: ComfyTextNode;
@@ -176,7 +177,7 @@
function getExtensions(): Extension[] {
// TODO
const readonly = false;
const placeholder = "Placeholder..."
const placeholder = ""
const dark_mode = true;
const stateExtensions: Extension[] = [
@@ -206,12 +207,22 @@
}
</script>
<div class="wrap">
<div class="codemirror-wrapper {classNames}" bind:this={element} />
<div class="code-editor-wrapper">
<Block>
<BlockTitle>{widget.attrs.title}</BlockTitle>
<div class="wrap">
<div class="codemirror-wrapper {classNames}" bind:this={element} />
</div>
</Block>
</div>
<!-- <CodeMirror bind:value={$nodeValue} {styles} /> -->
<style lang="scss">
.code-editor-wrapper {
:global(> .block) {
background: var(--panel-background-fill) !important;
}
}
.wrap {
display: flex;
flex-direction: column;

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import ComfyApp, { type SerializedAppState } from "$lib/components/ComfyApp";
import queueState from "$lib/stores/queueState";
import workflowState, { ComfyWorkflow } from "$lib/stores/workflowState";
import workflowState, { ComfyBoxWorkflow } from "$lib/stores/workflowState";
import { getNodeInfo } from "$lib/utils"
import { Link, Toolbar } from "framework7-svelte"
@@ -14,7 +14,7 @@
export let app: ComfyApp = undefined;
let layoutState: WritableLayoutStateStore = null;
let fileInput: HTMLInputElement = undefined;
let workflow: ComfyWorkflow | null = null;
let workflow: ComfyBoxWorkflow | null = null;
$: workflow = $workflowState.activeWorkflow;

View File

@@ -4,13 +4,13 @@
import type ComfyApp from "$lib/components/ComfyApp";
import { writable, type Writable } from "svelte/store";
import type { WritableLayoutStateStore } from "$lib/stores/layoutStates";
import workflowState, { type ComfyWorkflow } from "$lib/stores/workflowState";
import workflowState, { type ComfyBoxWorkflow } from "$lib/stores/workflowState";
export let subworkflowID: number = -1;
export let app: ComfyApp
// TODO move
let workflow: ComfyWorkflow | null = null
let workflow: ComfyBoxWorkflow | null = null
let layoutState: WritableLayoutStateStore | null = null;
$: workflow = $workflowState.activeWorkflow;

View File

@@ -6,10 +6,13 @@ body {
// Disable pull to refresh
overscroll-behavior-y: contain;
}
#app-root {
background: var(--body-background-fill);
width: 100%;
height: 100%;
margin: 0px;
font-family: Arial;
}
:root {
@@ -125,6 +128,7 @@ body {
border-color: var(--comfy-disabled-textbox-border-color);
box-shadow: 0 0 0 var(--shadow-spread) transparent, rgba(0, 0, 0, 0.08) 0px 2px 4px 0px inset;
cursor: not-allowed;
color: var(--comfy-disabled-label-color);
}
@mixin disable-inputs {
@@ -216,3 +220,8 @@ button {
}
}
// fixes drop item shadow not appearing when dragging template items onto workflow
:global([data-is-dnd-shadow-item]) {
min-height: 5rem;
}

View File

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

View File

@@ -10,6 +10,7 @@
"checkJs": true,
"strict": false,
"baseUrl": "./src",
"typeRoots": ["./node_modules/@types/", "./src"],
"paths": {
"$lib": ["lib"],
"$lib/*": ["lib/*"]

View File

@@ -6,13 +6,17 @@ import FullReload from 'vite-plugin-full-reload';
import { viteStaticCopy } from 'vite-plugin-static-copy'
import removeConsole from 'vite-plugin-svelte-console-remover';
import glsl from 'vite-plugin-glsl';
import { execSync } from "child_process"
const isProduction = process.env.NODE_ENV === "production";
console.log("Production build: " + isProduction)
const commitHash = execSync('git rev-parse HEAD').toString();
console.log("Commit: " + commitHash)
export default defineConfig({
define: {
"__GIT_COMMIT_HASH__": '"test"'
"__GIT_COMMIT_HASH__": JSON.stringify(commitHash)
},
clearScreen: false,
base: "./",