34 Commits

Author SHA1 Message Date
space-nuko
45f7a8d2c1 Fix title 2023-05-28 00:47:51 -05:00
space-nuko
60d0fb3128 Update img2img masked workflow 2023-05-28 00:47:51 -05:00
space-nuko
9a29e124e9 Better combo menu width handling 2023-05-28 00:47:51 -05:00
space-nuko
c255e5b425 Show title for cut off select item entries 2023-05-28 00:47:51 -05:00
space-nuko
3b9d4533f9 Preserve mask when sending output into image upload 2023-05-28 00:47:48 -05:00
space-nuko
cb9e8540a0 Mask canvas for masked img2img 2023-05-28 00:47:27 -05:00
space-nuko
7e2b6111dd Merge pull request #79 from space-nuko/error-handling
Better error display
2023-05-28 00:46:47 -05:00
space-nuko
88d99d2bcb Improve 2023-05-27 09:35:19 -05:00
space-nuko
a3eddfc350 Use pre 2023-05-27 02:07:25 -05:00
space-nuko
5cb93e6581 Fix 2023-05-27 02:04:20 -05:00
space-nuko
17d6e68b75 Update 2023-05-27 02:02:32 -05:00
space-nuko
35b991728f Fix 2023-05-27 01:53:49 -05:00
space-nuko
49ede4e2c8 Fix 2023-05-27 01:30:20 -05:00
space-nuko
a0b7418caf Improve error jumping 2023-05-27 01:13:06 -05:00
space-nuko
d144ec2ccd Show error list 2023-05-27 00:21:55 -05:00
space-nuko
72af089eab Support as-yet-released error API
upp
2023-05-26 23:04:25 -05:00
space-nuko
1da8dc35ec Reuse sent gallery image width/height 2023-05-26 19:50:19 -05:00
space-nuko
51e02f8179 Fix subgraphs queue string 2023-05-26 19:50:07 -05:00
space-nuko
32ed6cb5fd Merge pull request #75 from space-nuko/fix-graph-input-output-tags
More subgraph/template fixes
2023-05-26 18:00:18 -05:00
space-nuko
cdcf566a43 Strip user state for upscaleByModel 2023-05-26 17:09:09 -05:00
space-nuko
b3584dd2ad Default notifications for workflows 2023-05-26 17:06:23 -05:00
space-nuko
60bd989915 Strip tags from top-level nodes when inserting templates 2023-05-26 16:06:54 -05:00
space-nuko
2e6030beca Merge pull request #74 from space-nuko/fix-graph-input-output-tags
Support for subgraph actions/events + switch node
2023-05-26 15:16:23 -05:00
space-nuko
eb335e9be7 Fix litegraph 2023-05-26 14:19:05 -05:00
space-nuko
73e007844a Default preserve outputs true 2023-05-26 14:01:55 -05:00
space-nuko
e729bc6c46 Update default workflow 2023-05-26 13:57:42 -05:00
space-nuko
d11f66c5ac fix HR not setting thumbnails 2023-05-26 13:33:05 -05:00
space-nuko
9f5b14a2bf Update default workflow 2023-05-26 13:27:33 -05:00
space-nuko
74bad3bd1e Update default workflow 2023-05-26 13:11:34 -05:00
space-nuko
8e27a2611c Switch node 2023-05-26 12:59:38 -05:00
space-nuko
9faba38754 Merge pull request #73 from space-nuko/fix-graph-input-output-tags
Fixes for tag scoping
2023-05-25 22:18:17 -05:00
space-nuko
521ebb0ccf Fix default workflow 2023-05-25 22:13:38 -05:00
space-nuko
684e115f30 Fixes for tag scoping 2023-05-25 21:59:03 -05:00
space-nuko
6ba17176c6 Merge pull request #72 from space-nuko/builtin-templates
Builtin templates
2023-05-25 21:19:25 -05:00
42 changed files with 26054 additions and 7754 deletions

View File

@@ -3,7 +3,7 @@
"comfyUIPort": 8188, "comfyUIPort": 8188,
"alwaysStripUserState": false, "alwaysStripUserState": false,
"promptForWorkflowName": false, "promptForWorkflowName": false,
"confirmWhenUnloadingUnsavedChanges": true, "confirmWhenUnloadingUnsavedChanges": false,
"builtInTemplates": ["ControlNet", "LoRA x5", "Model Loader", "Positive_Negative", "Seed Randomizer"], "builtInTemplates": ["ControlNet", "LoRA x5", "Model Loader", "Positive_Negative", "Seed Randomizer"],
"cacheBuiltInResources": true "cacheBuiltInResources": true
} }

View File

@@ -3331,17 +3331,17 @@
"title": "UI.Text", "title": "UI.Text",
"properties": { "properties": {
"tags": [], "tags": [],
"defaultValue": "masterpiece, 1girl, (yuri:1.2), city street, cityscape, open shirt, breasts, large breasts, nipples, shiny skin, full body, happy, red hair, red eyes, looking at another, eye contact, red skirt", "defaultValue": "masterpiece, 1girl, city street, cityscape, large shiny skin, full body, happy, red hair, red eyes, looking at another, eye contact, red skirt",
"multiline": true, "multiline": true,
"lines": 5, "lines": 5,
"maxLines": 5 "maxLines": 5
}, },
"widgets_values": [ "widgets_values": [
"masterpiece, 1girl, (yuri:1.2), city street, cityscape, open shirt, breasts, large breasts, nipples, shiny skin, full body, happy, red hair, red eyes, looking at another, eye contact, red skirt" "masterpiece, 1girl, city street, cityscape, large shiny skin, full body, happy, red hair, red eyes, looking at another, eye contact, red skirt"
], ],
"color": "#223", "color": "#223",
"bgColor": "#335", "bgColor": "#335",
"comfyValue": "masterpiece, 1girl, (yuri:1.2), city street, cityscape, open shirt, breasts, large breasts, nipples, shiny skin, full body, happy, red hair, red eyes, looking at another, eye contact, red skirt", "comfyValue": "masterpiece, 1girl, city street, cityscape, large shiny skin, full body, happy, red hair, red eyes, looking at another, eye contact, red skirt",
"shownOutputProperties": {}, "shownOutputProperties": {},
"saveUserState": true "saveUserState": true
}, },
@@ -3396,17 +3396,17 @@
"title": "UI.Text", "title": "UI.Text",
"properties": { "properties": {
"tags": [], "tags": [],
"defaultValue": "masterpiece, 1girl, (yuri:1.2), open shirt, breasts, medium breasts, nipples, city street, cityscape, full body, happy, blue hair, blue eyes, looking at another, eye contact, blue skirt", "defaultValue": "masterpiece, 1girl, city street, cityscape, full body, happy, blue hair, blue eyes, looking at another, eye contact, blue skirt",
"multiline": true, "multiline": true,
"lines": 5, "lines": 5,
"maxLines": 5 "maxLines": 5
}, },
"widgets_values": [ "widgets_values": [
"masterpiece, 1girl, (yuri:1.2), open shirt, breasts, medium breasts, nipples, city street, cityscape, full body, happy, blue hair, blue eyes, looking at another, eye contact, blue skirt" "masterpiece, 1girl, city street, cityscape, full body, happy, blue hair, blue eyes, looking at another, eye contact, blue skirt"
], ],
"color": "#223", "color": "#223",
"bgColor": "#335", "bgColor": "#335",
"comfyValue": "masterpiece, 1girl, (yuri:1.2), open shirt, breasts, medium breasts, nipples, city street, cityscape, full body, happy, blue hair, blue eyes, looking at another, eye contact, blue skirt", "comfyValue": "masterpiece, 1girl, city street, cityscape, full body, happy, blue hair, blue eyes, looking at another, eye contact, blue skirt",
"shownOutputProperties": {}, "shownOutputProperties": {},
"saveUserState": true "saveUserState": true
}, },
@@ -3461,17 +3461,17 @@
"title": "UI.Text", "title": "UI.Text",
"properties": { "properties": {
"tags": [], "tags": [],
"defaultValue": "masterpiece, 2girls, (yuri:1.2), open shirt, small breasts, nipples, city street, cityscape, full body, happy, yellow hair, yellow eyes, looking at another, eye contact, yellow skirt", "defaultValue": "masterpiece, 2girls, small city street, cityscape, full body, happy, yellow hair, yellow eyes, looking at another, eye contact, yellow skirt",
"multiline": true, "multiline": true,
"lines": 5, "lines": 5,
"maxLines": 5 "maxLines": 5
}, },
"widgets_values": [ "widgets_values": [
"masterpiece, 2girls, (yuri:1.2), open shirt, small breasts, nipples, city street, cityscape, full body, happy, yellow hair, yellow eyes, looking at another, eye contact, yellow skirt" "masterpiece, 2girls, small city street, cityscape, full body, happy, yellow hair, yellow eyes, looking at another, eye contact, yellow skirt"
], ],
"color": "#223", "color": "#223",
"bgColor": "#335", "bgColor": "#335",
"comfyValue": "masterpiece, 2girls, (yuri:1.2), open shirt, small breasts, nipples, city street, cityscape, full body, happy, yellow hair, yellow eyes, looking at another, eye contact, yellow skirt", "comfyValue": "masterpiece, 2girls, small city street, cityscape, full body, happy, yellow hair, yellow eyes, looking at another, eye contact, yellow skirt",
"shownOutputProperties": {}, "shownOutputProperties": {},
"saveUserState": true "saveUserState": true
}, },
@@ -3526,17 +3526,17 @@
"title": "UI.Text", "title": "UI.Text",
"properties": { "properties": {
"tags": [], "tags": [],
"defaultValue": "masterpiece, 2girls, (yuri:1.2), open shirt, flat chest, nipples, city street, cityscape, full body, happy, green hair, green eyes, looking at another, eye contact, green skirt", "defaultValue": "masterpiece, 2girls, city street, cityscape, full body, happy, green hair, green eyes, looking at another, eye contact, green skirt",
"multiline": true, "multiline": true,
"lines": 5, "lines": 5,
"maxLines": 5 "maxLines": 5
}, },
"widgets_values": [ "widgets_values": [
"masterpiece, 2girls, (yuri:1.2), open shirt, flat chest, nipples, city street, cityscape, full body, happy, green hair, green eyes, looking at another, eye contact, green skirt" "masterpiece, 2girls, city street, cityscape, full body, happy, green hair, green eyes, looking at another, eye contact, green skirt"
], ],
"color": "#223", "color": "#223",
"bgColor": "#335", "bgColor": "#335",
"comfyValue": "masterpiece, 2girls, (yuri:1.2), open shirt, flat chest, nipples, city street, cityscape, full body, happy, green hair, green eyes, looking at another, eye contact, green skirt", "comfyValue": "masterpiece, 2girls, city street, cityscape, full body, happy, green hair, green eyes, looking at another, eye contact, green skirt",
"shownOutputProperties": {}, "shownOutputProperties": {},
"saveUserState": true "saveUserState": true
}, },
@@ -4706,17 +4706,17 @@
"title": "UI.Text", "title": "UI.Text",
"properties": { "properties": {
"tags": [], "tags": [],
"defaultValue": "nsfw, masterpiece, 4girls, multiple girls, city street, cityscape, landscape, jeans, shoes, shirt, kanpai, happy, red hair, yellow hair, blue hair, green hair, looking at another, eye contact", "defaultValue": "masterpiece, 4girls, multiple girls, city street, cityscape, landscape, jeans, shoes, shirt, kanpai, happy, red hair, yellow hair, blue hair, green hair, looking at another, eye contact",
"multiline": true, "multiline": true,
"lines": 5, "lines": 5,
"maxLines": 5 "maxLines": 5
}, },
"widgets_values": [ "widgets_values": [
"nsfw, masterpiece, 4girls, multiple girls, city street, cityscape, landscape, jeans, shoes, shirt, kanpai, happy, red hair, yellow hair, blue hair, green hair, looking at another, eye contact" "masterpiece, 4girls, multiple girls, city street, cityscape, landscape, jeans, shoes, shirt, kanpai, happy, red hair, yellow hair, blue hair, green hair, looking at another, eye contact"
], ],
"color": "#223", "color": "#223",
"bgColor": "#335", "bgColor": "#335",
"comfyValue": "nsfw, masterpiece, 4girls, multiple girls, city street, cityscape, landscape, jeans, shoes, shirt, kanpai, happy, red hair, yellow hair, blue hair, green hair, looking at another, eye contact", "comfyValue": "masterpiece, 4girls, multiple girls, city street, cityscape, landscape, jeans, shoes, shirt, kanpai, happy, red hair, yellow hair, blue hair, green hair, looking at another, eye contact",
"shownOutputProperties": {}, "shownOutputProperties": {},
"saveUserState": true "saveUserState": true
}, },
@@ -10027,4 +10027,4 @@
], ],
"scale": 1 "scale": 1
} }
} }

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, "comfyBoxWorkflow": true,
"createdBy": "ComfyBox", "createdBy": "ComfyBox",
"version": 1, "version": 1,
"commitHash": "574d3170a4e829df366dc12c3aaa049121052d8f\n", "commitHash": "60bd9899150678dbc46df543ac16e1099d58a07f\n",
"workflow": { "workflow": {
"last_node_id": 0, "last_node_id": 0,
"last_link_id": 0, "last_link_id": 0,
@@ -391,20 +391,7 @@
"title": "UI.Gallery", "title": "UI.Gallery",
"properties": { "properties": {
"tags": [], "tags": [],
"defaultValue": [ "defaultValue": [],
{
"isComfyBoxImageMetadata": true,
"comfyUIFile": {
"filename": "ComfyUI_04712_.png",
"subfolder": "",
"type": "output"
},
"name": "File",
"tags": [],
"width": 6656,
"height": 4096
}
],
"index": 0, "index": 0,
"updateMode": "replace", "updateMode": "replace",
"autoSelectOnUpdate": true "autoSelectOnUpdate": true
@@ -412,20 +399,7 @@
"widgets_values": [], "widgets_values": [],
"color": "#223", "color": "#223",
"bgColor": "#335", "bgColor": "#335",
"comfyValue": [ "comfyValue": [],
{
"isComfyBoxImageMetadata": true,
"comfyUIFile": {
"filename": "ComfyUI_04712_.png",
"subfolder": "",
"type": "output"
},
"name": "File",
"tags": [],
"width": 6656,
"height": 4096
}
],
"shownOutputProperties": {}, "shownOutputProperties": {},
"saveUserState": false "saveUserState": false
}, },
@@ -528,39 +502,13 @@
], ],
"title": "UI.ImageUpload", "title": "UI.ImageUpload",
"properties": { "properties": {
"defaultValue": [ "defaultValue": [],
{
"isComfyBoxImageMetadata": true,
"comfyUIFile": {
"filename": "ComfyUI_05835_.png",
"type": "output",
"subfolder": ""
},
"name": "File",
"tags": [],
"width": 640,
"height": 768
}
],
"tags": [] "tags": []
}, },
"widgets_values": [], "widgets_values": [],
"color": "#223", "color": "#223",
"bgColor": "#335", "bgColor": "#335",
"comfyValue": [ "comfyValue": [],
{
"isComfyBoxImageMetadata": true,
"comfyUIFile": {
"filename": "ComfyUI_05835_.png",
"type": "output",
"subfolder": ""
},
"name": "File",
"tags": [],
"width": 640,
"height": 768
}
],
"shownOutputProperties": {}, "shownOutputProperties": {},
"saveUserState": false "saveUserState": false
}, },
@@ -684,7 +632,8 @@
"attrs": { "attrs": {
"title": "Upscale by Model", "title": "Upscale by Model",
"queuePromptButtonName": "Queue Prompt", "queuePromptButtonName": "Queue Prompt",
"queuePromptButtonRunWorkflow": true "queuePromptButtonRunWorkflow": true,
"showDefaultNotifications": true
}, },
"layout": { "layout": {
"root": "46a08906-61a9-4a23-881b-9615cf165e33", "root": "46a08906-61a9-4a23-881b-9615cf165e33",
@@ -930,4 +879,4 @@
], ],
"scale": 1 "scale": 1
} }
} }

View File

@@ -285,6 +285,19 @@ function relocateNodes(nodes: SerializedLGraphNode[]): SerializedLGraphNode[] {
return nodes; return nodes;
} }
/*
* Strips tags from top-level nodes
*/
function stripTags(nodes: SerializedLGraphNode[]): SerializedLGraphNode[] {
for (const node of nodes) {
if (Array.isArray(node.properties.tags)) {
node.properties.tags = []
}
}
return nodes;
}
function pruneDetachedLinks(nodes: SerializedLGraphNode[], links: SerializedTemplateLink[]): [SerializedLGraphNode[], SerializedTemplateLink[]] { function pruneDetachedLinks(nodes: SerializedLGraphNode[], links: SerializedTemplateLink[]): [SerializedLGraphNode[], SerializedTemplateLink[]] {
const nodeIds = new Set(nodes.map(n => n.id)); const nodeIds = new Set(nodes.map(n => n.id));
@@ -338,6 +351,7 @@ export function serializeTemplate(canvas: ComfyGraphCanvas, template: ComfyBoxTe
uiState.update(s => { s.forceSaveUserState = null; return s; }); uiState.update(s => { s.forceSaveUserState = null; return s; });
nodes = relocateNodes(nodes); nodes = relocateNodes(nodes);
nodes = stripTags(nodes);
[nodes, links] = pruneDetachedLinks(nodes, links); [nodes, links] = pruneDetachedLinks(nodes, links);
const svg = renderSvg(canvas, graph, TEMPLATE_SVG_PADDING); const svg = renderSvg(canvas, graph, TEMPLATE_SVG_PADDING);

View File

@@ -249,6 +249,11 @@ export default class ComfyGraph extends LGraph {
node_data.id = uuidv4(); node_data.id = uuidv4();
templateNodeIDToNewNode[prevNodeId] = node templateNodeIDToNewNode[prevNodeId] = node
// Strip tags from top-level nodes
if (Array.isArray(node_data.properties.tags)) {
node_data.properties.tags = []
}
node.configure(node_data); node.configure(node_data);
if (mapping) { if (mapping) {

View File

@@ -1,14 +1,15 @@
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 { BuiltInSlotShape, ContextMenu, LGraphCanvas, LGraphNode, LLink, LiteGraph, NodeMode, Subgraph, TitleMode, type ContextMenuItem, type IContextMenuItem, type MouseEventExt, type NodeID, type Vector2, type Vector4, LGraph, type SlotIndex, type SlotNameOrIndex } from "@litegraph-ts/core";
import { get, type Unsubscriber } from "svelte/store"; import { get, type Unsubscriber } from "svelte/store";
import { createTemplate, serializeTemplate, type ComfyBoxTemplate, type SerializedComfyBoxTemplate } from "./ComfyBoxTemplate";
import type ComfyGraph from "./ComfyGraph"; import type ComfyGraph from "./ComfyGraph";
import type { ComfyGraphErrorLocation, ComfyGraphErrors, ComfyNodeErrors } from "./apiErrors";
import type ComfyApp from "./components/ComfyApp"; import type ComfyApp from "./components/ComfyApp";
import { ComfyReroute } from "./nodes"; import { ComfyReroute } from "./nodes";
import notify from "./notify";
import layoutStates, { type ContainerLayout } from "./stores/layoutStates"; import layoutStates, { type ContainerLayout } from "./stores/layoutStates";
import queueState from "./stores/queueState"; import queueState from "./stores/queueState";
import selectionState from "./stores/selectionState"; import selectionState from "./stores/selectionState";
import templateState from "./stores/templateState"; import templateState from "./stores/templateState";
import { createTemplate, type ComfyBoxTemplate, serializeTemplate, type SerializedComfyBoxTemplate } from "./ComfyBoxTemplate";
import notify from "./notify";
import { calcNodesBoundingBox } from "./utils"; import { calcNodesBoundingBox } from "./utils";
export type SerializedGraphCanvasState = { export type SerializedGraphCanvasState = {
@@ -20,11 +21,22 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
app: ComfyApp | null; app: ComfyApp | null;
private _unsubscribe: Unsubscriber; private _unsubscribe: Unsubscriber;
isExportingSVG: boolean = false; isExportingSVG: boolean = false;
activeErrors?: ComfyGraphErrors = null;
blinkError: ComfyGraphErrorLocation | null = null;
blinkErrorTime: number = 0;
highlightNodeAndInput: [LGraphNode, number] | null = null;
get comfyGraph(): ComfyGraph | null { get comfyGraph(): ComfyGraph | null {
return this.graph as ComfyGraph; return this.graph as ComfyGraph;
} }
clearErrors() {
this.activeErrors = null;
this.blinkError = null;
this.blinkErrorTime = 0;
this.highlightNodeAndInput = null;
}
constructor( constructor(
app: ComfyApp, app: ComfyApp,
canvas: HTMLCanvasElement | string, canvas: HTMLCanvasElement | string,
@@ -90,10 +102,18 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
let state = get(queueState); let state = get(queueState);
let ss = get(selectionState); let ss = get(selectionState);
const isRunningNode = node.id === state.runningNodeID
const isRunningNode = node.id == state.runningNodeID
const nodeErrors = this.activeErrors?.errorsByID[node.id];
const isHighlightedNode = this.highlightNodeAndInput && this.highlightNodeAndInput[0].id === node.id;
if (this.blinkErrorTime > 0) {
this.blinkErrorTime -= this.graph.elapsed_time;
}
let color = null; let color = null;
let thickness = 1; let thickness = 1;
let blink = false;
// if (this._selectedNodes.has(node.id)) { // if (this._selectedNodes.has(node.id)) {
// color = "yellow"; // color = "yellow";
// thickness = 5; // thickness = 5;
@@ -104,6 +124,29 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
else if (isRunningNode) { else if (isRunningNode) {
color = "#0f0"; color = "#0f0";
} }
else if (nodeErrors) {
const hasExecutionError = nodeErrors.find(e => e.errorType === "execution");
if (hasExecutionError) {
blink = true;
color = "#f0f";
}
else {
color = "red";
}
thickness = 2
}
else if (isHighlightedNode) {
color = "cyan";
thickness = 2
}
if (blink) {
if (nodeErrors && nodeErrors.includes(this.blinkError) && this.blinkErrorTime > 0) {
if ((Math.floor(this.blinkErrorTime / 2)) % 2 === 0) {
color = null;
}
}
}
if (color) { if (color) {
this.drawNodeOutline(node, ctx, size, mouseOver, fgColor, bgColor, color, thickness) this.drawNodeOutline(node, ctx, size, mouseOver, fgColor, bgColor, color, thickness)
@@ -114,6 +157,54 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
ctx.fillRect(0, 0, size[0] * (state.progress.value / state.progress.max), 6); ctx.fillRect(0, 0, size[0] * (state.progress.value / state.progress.max), 6);
ctx.fillStyle = bgColor; ctx.fillStyle = bgColor;
} }
if (nodeErrors) {
this.drawFailedValidationInputs(node, nodeErrors, color, ctx);
}
if (isHighlightedNode) {
let draw = true;
if (this.blinkErrorTime > 0) {
if ((Math.floor(this.blinkErrorTime / 2)) % 2 === 0) {
draw = false;
}
}
if (draw) {
const [node, inputSlot] = this.highlightNodeAndInput;
ctx.lineWidth = 2;
ctx.strokeStyle = color;
this.highlightNodeInput(node, inputSlot, ctx);
}
}
}
private drawFailedValidationInputs(node: LGraphNode, errors: ComfyGraphErrorLocation[], color: string, ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 2;
ctx.strokeStyle = color || "red";
for (const errorLocation of errors) {
if (errorLocation.input != null) {
if (errorLocation === this.blinkError && this.blinkErrorTime > 0) {
if ((Math.floor(this.blinkErrorTime / 2)) % 2 === 0) {
continue;
}
}
this.highlightNodeInput(node, errorLocation.input.name, ctx);
}
}
}
private highlightNodeInput(node: LGraphNode, inputSlot: SlotNameOrIndex, ctx: CanvasRenderingContext2D) {
let inputIndex: number;
if (typeof inputSlot === "number")
inputIndex = inputSlot
else
inputIndex = node.findInputSlotIndexByName(inputSlot)
if (inputIndex !== -1) {
let pos = node.getConnectionPos(true, inputIndex);
ctx.beginPath();
ctx.arc(pos[0] - node.pos[0], pos[1] - node.pos[1], 12, 0, 2 * Math.PI, false)
ctx.stroke();
}
} }
private drawNodeOutline(node: LGraphNode, ctx: CanvasRenderingContext2D, size: Vector2, mouseOver: boolean, fgColor: string, bgColor: string, outlineColor: string, outlineThickness: number) { private drawNodeOutline(node: LGraphNode, ctx: CanvasRenderingContext2D, size: Vector2, mouseOver: boolean, fgColor: string, bgColor: string, outlineColor: string, outlineThickness: number) {
@@ -568,4 +659,61 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
} }
return false; return false;
} }
jumpToFirstError() {
this.jumpToError(0);
}
jumpToError(index: number | ComfyGraphErrorLocation) {
if (this.activeErrors == null) {
return;
}
let error;
if (typeof index === "number") {
error = this.activeErrors.errors[index]
}
else {
error = index;
}
if (error == null) {
return;
}
const rootGraph = this.graph.getRootGraph()
if (rootGraph == null) {
return
}
const node = rootGraph.getNodeByIdRecursive(error.nodeID);
if (node == null) {
notify(`Couldn't find node '${error.comfyNodeType}' (${error.nodeID})`, { type: "warning" })
return
}
this.jumpToNode(node)
this.highlightNodeAndInput = null;
this.blinkError = error;
this.blinkErrorTime = 20;
}
jumpToNode(node: LGraphNode) {
this.closeAllSubgraphs();
const subgraphs = Array.from(node.iterateParentSubgraphNodes()).reverse();
for (const subgraph of subgraphs) {
this.openSubgraph(subgraph.subgraph)
}
this.centerOnNode(node);
}
jumpToNodeAndInput(node: LGraphNode, slotIndex: number) {
this.jumpToNode(node);
this.highlightNodeAndInput = [node, slotIndex];
this.blinkErrorTime = 20;
}
} }

View File

@@ -1,11 +1,12 @@
import type { Progress, SerializedPrompt, SerializedPromptInputsForNode, SerializedPromptInputsAll, SerializedPromptOutputs, SerializedAppState } from "./components/ComfyApp"; import type { Progress, SerializedPrompt, SerializedPromptInputsForNode, SerializedPromptInputsAll, SerializedPromptOutputs, SerializedAppState, SerializedPromptInput, SerializedPromptInputLink } from "./components/ComfyApp";
import type TypedEmitter from "typed-emitter"; import type TypedEmitter from "typed-emitter";
import EventEmitter from "events"; import EventEmitter from "events";
import type { ComfyImageLocation } from "$lib/utils"; import type { ComfyImageLocation } from "$lib/utils";
import type { SerializedLGraph, UUID } from "@litegraph-ts/core"; import type { SerializedLGraph, UUID } from "@litegraph-ts/core";
import type { SerializedLayoutState } from "./stores/layoutStates"; import type { SerializedLayoutState } from "./stores/layoutStates";
import type { ComfyNodeDef } from "./ComfyNodeDef"; import type { ComfyNodeDef, ComfyNodeDefInput } from "./ComfyNodeDef";
import type { WorkflowInstID } from "./stores/workflowState"; import type { WorkflowInstID } from "./stores/workflowState";
import type { ComfyAPIPromptErrorResponse } from "./apiErrors";
export type ComfyPromptRequest = { export type ComfyPromptRequest = {
client_id?: string, client_id?: string,
@@ -43,11 +44,12 @@ export type ComfyAPIHistoryItem = [
ComfyNodeID[] // good outputs ComfyNodeID[] // good outputs
] ]
export type ComfyAPIPromptResponse = { export type ComfyAPIPromptSuccessResponse = {
promptID?: PromptID, promptID: PromptID
error?: string
} }
export type ComfyAPIPromptResponse = ComfyAPIPromptSuccessResponse | ComfyAPIPromptErrorResponse
export type ComfyAPIHistoryEntry = { export type ComfyAPIHistoryEntry = {
prompt: ComfyAPIHistoryItem, prompt: ComfyAPIHistoryItem,
outputs: SerializedPromptOutputs outputs: SerializedPromptOutputs
@@ -92,7 +94,8 @@ type ComfyAPIEvents = {
executed: (promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutputs) => void, executed: (promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutputs) => void,
execution_start: (promptID: PromptID) => void, execution_start: (promptID: PromptID) => void,
execution_cached: (promptID: PromptID, nodes: ComfyNodeID[]) => void, execution_cached: (promptID: PromptID, nodes: ComfyNodeID[]) => void,
execution_error: (promptID: PromptID, message: string) => void, execution_interrupted: (error: ComfyInterruptedError) => void,
execution_error: (error: ComfyExecutionError) => void,
} }
export default class ComfyAPI { export default class ComfyAPI {
@@ -201,8 +204,11 @@ export default class ComfyAPI {
case "execution_cached": case "execution_cached":
this.eventBus.emit("execution_cached", msg.data.prompt_id, msg.data.nodes); this.eventBus.emit("execution_cached", msg.data.prompt_id, msg.data.nodes);
break; break;
case "execution_interrupted":
this.eventBus.emit("execution_interrupted", msg.data);
break;
case "execution_error": case "execution_error":
this.eventBus.emit("execution_error", msg.data.prompt_id, msg.data.message); this.eventBus.emit("execution_error", msg.data);
break; break;
default: default:
console.warn("Unhandled message:", event.data); console.warn("Unhandled message:", event.data);

270
src/lib/apiErrors.ts Normal file
View File

@@ -0,0 +1,270 @@
import type { NodeID } from "@litegraph-ts/core"
import type { ComfyNodeDefInput } from "./ComfyNodeDef"
import type { ComfyNodeID, PromptID } from "./api"
import type { SerializedPromptInputLink } from "./components/ComfyApp"
import type { WorkflowError, WorkflowInstID } from "./stores/workflowState"
import { exclude_internal_props } from "svelte/internal"
import type ComfyGraphCanvas from "./ComfyGraphCanvas"
import type { QueueEntry } from "./stores/queueState"
enum ComfyPromptErrorType {
NoOutputs = "prompt_no_outputs",
OutputsFailedValidation = "prompt_outputs_failed_validation",
}
export interface ComfyPromptError<T = any> {
type: ComfyPromptErrorType,
message: string,
details: string,
extra_info: T
}
export interface CPENoOutputs extends ComfyPromptError {
type: ComfyPromptErrorType.NoOutputs
}
export interface CPEOutputsFailedValidation extends ComfyPromptError {
type: ComfyPromptErrorType.OutputsFailedValidation
}
export enum ComfyNodeErrorType {
RequiredInputMissing = "required_input_missing",
BadLinkedInput = "bad_linked_input",
ReturnTypeMismatch = "return_type_mismatch",
InvalidInputType = "invalid_input_type",
ValueSmallerThanMin = "value_smaller_than_min",
ValueBiggerThanMax = "value_bigger_than_max",
CustomValidationFailed = "custom_validation_failed",
ValueNotInList = "value_not_in_list",
ExceptionDuringValidation = "exception_during_validation",
ExceptionDuringInnerValidation = "exception_during_inner_validation",
}
export interface ComfyNodeError<T = any> {
type: ComfyNodeErrorType,
message: string,
details: string,
extra_info: T
}
export type ComfyNodeErrors = {
errors: ComfyNodeError[],
dependent_outputs: ComfyNodeID[],
class_type: string
}
export type InputWithValue = {
input_name: string,
input_config: ComfyNodeDefInput,
received_value: any
}
function isInputWithValue(param: any): param is InputWithValue {
return param && "input_name" in param;
}
export type InputWithValueAndException = InputWithValue & {
exception_message: string
}
export type InputWithLinkedNode = {
input_name: string,
input_config: ComfyNodeDefInput,
linked_node: SerializedPromptInputLink
}
export type ValidationException = {
exception_type: string,
traceback: string[]
}
function isValidationException(param: any): param is ValidationException {
return param && "exception_type" in param && "traceback" in param;
}
export interface CNERequiredInputMissing extends ComfyNodeError<{ input_name: string }> {
type: ComfyNodeErrorType.RequiredInputMissing
}
export interface CNEBadLinkedInput extends ComfyNodeError<InputWithValue> {
type: ComfyNodeErrorType.BadLinkedInput
}
export interface CNEReturnTypeMismatch extends ComfyNodeError<InputWithLinkedNode & { received_type: string }> {
type: ComfyNodeErrorType.ReturnTypeMismatch
}
export interface CNEInvalidInputType extends ComfyNodeError<InputWithValue & { exception_message: string }> {
type: ComfyNodeErrorType.InvalidInputType
}
export interface CNEValueSmallerThanMin extends ComfyNodeError<InputWithValue> {
type: ComfyNodeErrorType.ValueSmallerThanMin
}
export interface CNEValueBiggerThanMax extends ComfyNodeError<InputWithValue> {
type: ComfyNodeErrorType.ValueBiggerThanMax
}
export interface CNECustomValidationFailed extends ComfyNodeError<InputWithValue> {
type: ComfyNodeErrorType.CustomValidationFailed
}
export interface CNEValueNotInList extends ComfyNodeError<InputWithValue> {
type: ComfyNodeErrorType.ValueNotInList
}
export interface CNEExceptionDuringValidation extends ComfyNodeError<ValidationException> {
type: ComfyNodeErrorType.ExceptionDuringValidation
}
export interface CNEExceptionDuringInnerValidation extends ComfyNodeError<InputWithLinkedNode & ValidationException> {
type: ComfyNodeErrorType.ExceptionDuringInnerValidation
}
export type ComfyAPIPromptErrorResponse = {
error: ComfyPromptError,
node_errors: Record<ComfyNodeID, ComfyNodeErrors>,
}
export type ComfyInterruptedError = {
prompt_id: PromptID,
node_id: ComfyNodeID,
node_type: string,
executed: ComfyNodeID[]
}
export type ComfyExecutionError = ComfyInterruptedError & {
exception_message: string,
exception_type: string,
traceback: string[],
current_inputs: any[],
current_outputs: any[][],
}
export function formatValidationError(error: ComfyAPIPromptErrorResponse) {
return `${error.error.message}: ${error.error.details}`
}
export function formatExecutionError(error: ComfyExecutionError) {
return error.exception_message
}
export type ComfyGraphErrorInput = {
name: string,
config?: ComfyNodeDefInput,
receivedValue?: any,
receivedType?: string,
linkedNode?: SerializedPromptInputLink
}
export type ComfyGraphErrorLocation = {
workflowID: WorkflowInstID,
nodeID: NodeID,
comfyNodeType: string,
errorType: ComfyNodeErrorType | "execution",
message: string,
dependentOutputs: NodeID[],
queueEntry: QueueEntry,
input?: ComfyGraphErrorInput,
exceptionMessage?: string,
exceptionType?: string,
traceback?: string[],
inputValues?: any[],
outputValues?: any[][],
}
export type ComfyGraphErrors = {
message: string,
errors: ComfyGraphErrorLocation[],
errorsByID: Record<NodeID, ComfyGraphErrorLocation[]>
}
export function validationErrorToGraphErrors(workflowID: WorkflowInstID, validationError: ComfyAPIPromptErrorResponse, queueEntry: QueueEntry): ComfyGraphErrors {
const errorsByID: Record<NodeID, ComfyGraphErrorLocation[]> = {}
for (const [nodeID, nodeErrors] of Object.entries(validationError.node_errors)) {
errorsByID[nodeID] = nodeErrors.errors.map(e => {
const loc: ComfyGraphErrorLocation = {
workflowID,
nodeID,
comfyNodeType: nodeErrors.class_type,
errorType: e.type,
message: e.message,
dependentOutputs: nodeErrors.dependent_outputs,
queueEntry
}
if (isInputWithValue(e.extra_info)) {
loc.input = {
name: e.extra_info.input_name,
config: e.extra_info.input_config,
receivedValue: e.extra_info.received_value
}
if ("received_type" in e.extra_info) {
loc.input.receivedType = e.extra_info.received_type as string;
}
if ("linked_node" in e.extra_info) {
loc.input.linkedNode = e.extra_info.linked_node as SerializedPromptInputLink;
}
}
if ("exception_message" in e.extra_info) {
loc.exceptionMessage = e.extra_info.exception_message as "string"
}
if (isValidationException(e.extra_info)) {
loc.exceptionType = e.extra_info.exception_type;
loc.traceback = e.extra_info.traceback;
}
return loc;
})
}
return {
message: validationError.error.message,
errors: Object.values(errorsByID).flatMap(e => e),
errorsByID
}
}
export function executionErrorToGraphErrors(workflowID: WorkflowInstID, executionError: ComfyExecutionError, queueEntry: QueueEntry): ComfyGraphErrors {
const errorsByID: Record<NodeID, ComfyGraphErrorLocation[]> = {}
errorsByID[executionError.node_id] = [{
workflowID,
nodeID: executionError.node_id,
comfyNodeType: executionError.node_type,
errorType: "execution",
message: executionError.exception_message,
dependentOutputs: [], // TODO
queueEntry,
exceptionMessage: executionError.exception_message,
exceptionType: executionError.exception_type,
traceback: executionError.traceback,
inputValues: executionError.current_inputs,
outputValues: executionError.current_outputs,
}]
return {
message: executionError.exception_message,
errors: Object.values(errorsByID).flatMap(e => e),
errorsByID
}
}
export function workflowErrorToGraphErrors(workflowID: WorkflowInstID, workflowError: WorkflowError, queueEntry: QueueEntry): ComfyGraphErrors {
if (workflowError.type === "validation") {
return validationErrorToGraphErrors(workflowID, workflowError.error, queueEntry)
}
else {
return executionErrorToGraphErrors(workflowID, workflowError.error, queueEntry)
}
}

View File

@@ -1,4 +1,4 @@
import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyNodeID, type ComfyPromptRequest, type PromptID, type QueueItemType } from "$lib/api"; import ComfyAPI, { type iomfyAPIPromptResponse, type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyNodeID, type ComfyPromptRequest, type PromptID, type QueueItemType } from "$lib/api";
import { parsePNGMetadata } from "$lib/pnginfo"; import { parsePNGMetadata } from "$lib/pnginfo";
import { BuiltInSlotType, LGraphCanvas, LGraphNode, LiteGraph, NodeMode, type INodeInputSlot, type LGraphNodeConstructor, type NodeID, type NodeTypeOpts, type SerializedLGraph, type SlotIndex } from "@litegraph-ts/core"; import { BuiltInSlotType, LGraphCanvas, LGraphNode, LiteGraph, NodeMode, type INodeInputSlot, type LGraphNodeConstructor, type NodeID, type NodeTypeOpts, type SerializedLGraph, type SlotIndex } from "@litegraph-ts/core";
import A1111PromptModal from "./modal/A1111PromptModal.svelte"; import A1111PromptModal from "./modal/A1111PromptModal.svelte";
@@ -29,7 +29,7 @@ import queueState from "$lib/stores/queueState";
import selectionState from "$lib/stores/selectionState"; import selectionState from "$lib/stores/selectionState";
import uiState from "$lib/stores/uiState"; import uiState from "$lib/stores/uiState";
import workflowState, { ComfyBoxWorkflow, type WorkflowAttributes, type WorkflowInstID } from "$lib/stores/workflowState"; import workflowState, { ComfyBoxWorkflow, type WorkflowAttributes, type WorkflowInstID } from "$lib/stores/workflowState";
import { readFileToText, type SerializedPromptOutput } from "$lib/utils"; import { playSound, readFileToText, type SerializedPromptOutput } from "$lib/utils";
import { basename, capitalize, download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range } from "$lib/utils"; import { basename, capitalize, download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range } from "$lib/utils";
import { tick } from "svelte"; import { tick } from "svelte";
import { type SvelteComponentDev } from "svelte/internal"; import { type SvelteComponentDev } from "svelte/internal";
@@ -38,6 +38,7 @@ import ComfyPromptSerializer, { isActiveBackendNode, nodeHasTag, UpstreamNodeLoc
import DanbooruTags from "$lib/DanbooruTags"; import DanbooruTags from "$lib/DanbooruTags";
import { deserializeTemplateFromSVG, type SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate"; import { deserializeTemplateFromSVG, type SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate";
import templateState from "$lib/stores/templateState"; import templateState from "$lib/stores/templateState";
import { formatValidationError, type ComfyAPIPromptErrorResponse, formatExecutionError, type ComfyExecutionError } from "$lib/apiErrors";
export const COMFYBOX_SERIAL_VERSION = 1; export const COMFYBOX_SERIAL_VERSION = 1;
@@ -91,7 +92,8 @@ export type SerializedAppState = {
} }
/** [link_origin, link_slot_index] | input_value */ /** [link_origin, link_slot_index] | input_value */
export type SerializedPromptInput = [ComfyNodeID, number] | any export type SerializedPromptInputLink = [ComfyNodeID, number]
export type SerializedPromptInput = SerializedPromptInputLink | any
export type SerializedPromptInputs = Record<string, SerializedPromptInput>; export type SerializedPromptInputs = Record<string, SerializedPromptInput>;
@@ -307,7 +309,7 @@ export default class ComfyApp {
if (errors && errors.length > 0) if (errors && errors.length > 0)
error = "Error(s) loading builtin templates:\n" + errors.join("\n"); error = "Error(s) loading builtin templates:\n" + errors.join("\n");
console.log(`Loaded {templates.length} builtin templates.`); console.log(`Loaded ${templates.length} builtin templates.`);
return [templates, error] return [templates, error]
}) })
@@ -597,9 +599,33 @@ export default class ComfyApp {
queueState.executionCached(promptID, nodes) queueState.executionCached(promptID, nodes)
}); });
this.api.addEventListener("execution_error", (promptID: PromptID, message: string) => { this.api.addEventListener("execution_error", (error: ComfyExecutionError) => {
queueState.executionError(promptID, message) const completedEntry = queueState.executionError(error)
notify(`Execution error: ${message}`, { type: "error", timeout: 10000 }) let workflow: ComfyBoxWorkflow | null;
if (completedEntry) {
const workflowID = completedEntry.entry.extraData.workflowID;
if (workflowID) {
workflow = workflowState.getWorkflow(workflowID)
}
}
if (workflow) {
workflowState.executionError(workflow.id, error.prompt_id)
notify(
`Execution error in workflow "${workflow.attrs.title}".\nClick for details.`,
{
type: "error",
showBar: true,
timeout: 15 * 1000,
onClick: () => {
uiState.update(s => { s.activeError = error.prompt_id; return s })
}
})
}
else {
const message = formatExecutionError(error);
notify(`Execution error: ${message}`, { type: "error", timeout: 10000 })
}
}); });
this.api.init(); this.api.init();
@@ -891,7 +917,7 @@ export default class ComfyApp {
if (workflow.attrs.queuePromptButtonRunWorkflow) { if (workflow.attrs.queuePromptButtonRunWorkflow) {
// Hold control to queue at the front // Hold control to queue at the front
const num = this.ctrlDown ? -1 : 0; const num = this.ctrlDown ? -1 : 0;
this.queuePrompt(num, 1); this.queuePrompt(workflow, num, 1);
} }
} }
@@ -941,14 +967,8 @@ export default class ComfyApp {
return this.promptSerializer.serialize(workflow.graph, tag) return this.promptSerializer.serialize(workflow.graph, tag)
} }
async queuePrompt(num: number, batchCount: number = 1, tag: string | null = null) { async queuePrompt(targetWorkflow: ComfyBoxWorkflow, num: number, batchCount: number = 1, tag: string | null = null) {
const activeWorkflow = workflowState.getActiveWorkflow(); this.queueItems.push({ num, batchCount, workflow: targetWorkflow });
if (activeWorkflow == null) {
notify("No workflow is opened!", { type: "error" })
return;
}
this.queueItems.push({ num, batchCount, workflow: activeWorkflow });
// Only have one action process the items so each one gets a unique seed correctly // Only have one action process the items so each one gets a unique seed correctly
if (this.processingQueue) { if (this.processingQueue) {
@@ -958,6 +978,10 @@ export default class ComfyApp {
if (tag === "") if (tag === "")
tag = null; tag = null;
if (targetWorkflow.attrs.showDefaultNotifications) {
notify("Prompt queued.", { type: "info" });
}
this.processingQueue = true; this.processingQueue = true;
let workflow: ComfyBoxWorkflow; let workflow: ComfyBoxWorkflow;
@@ -968,11 +992,12 @@ export default class ComfyApp {
const thumbnails = [] const thumbnails = []
for (const node of workflow.graph.iterateNodesInOrderRecursive()) { for (const node of workflow.graph.iterateNodesInOrderRecursive()) {
if (node.mode !== NodeMode.ALWAYS || (tag != null && !nodeHasTag(node, tag))) if (node.mode !== NodeMode.ALWAYS || (tag != null && !nodeHasTag(node, tag, true)))
continue; continue;
if ("getPromptThumbnails" in node) { if ("getPromptThumbnails" in node) {
const thumbsToAdd = (node as ComfyGraphNode).getPromptThumbnails(); const thumbsToAdd = (node as ComfyGraphNode).getPromptThumbnails();
console.warn("THUMBNAILS", thumbsToAdd)
if (thumbsToAdd) if (thumbsToAdd)
thumbnails.push(...thumbsToAdd) thumbnails.push(...thumbsToAdd)
} }
@@ -1008,8 +1033,9 @@ export default class ComfyApp {
thumbnails thumbnails
} }
let error: string | null = null; let error: ComfyAPIPromptErrorResponse | null = null;
let promptID: PromptID | null = null; let errorMes: string | null = null;
let errorPromptID: PromptID | null = null;
const request: ComfyPromptRequest = { const request: ComfyPromptRequest = {
number: num, number: num,
@@ -1019,22 +1045,36 @@ export default class ComfyApp {
try { try {
const response = await this.api.queuePrompt(request); const response = await this.api.queuePrompt(request);
if (response.error != null) { if ("error" in response) {
error = response.error; error = response;
errorMes = formatValidationError(error)
errorPromptID = queueState.promptError(workflow.id, response, p, extraData)
workflowState.promptError(workflow.id, errorPromptID)
} }
else { else {
queueState.afterQueued(workflow.id, response.promptID, num, p.output, extraData) queueState.afterQueued(workflow.id, response.promptID, num, p.output, extraData)
workflowState.afterQueued(workflow.id, response.promptID, p, extraData)
} }
} catch (err) { } catch (err) {
error = err?.toString(); errorMes = err?.toString();
} }
if (error != null) { if (error != null) {
const mes: string = error; notify(
notify(`Error queuing prompt: \n${mes} `, { type: "error" }) `Prompt validation failed.\nClick for details.`,
{
type: "error",
showBar: true,
timeout: 1000 * 15,
onClick: () => {
uiState.update(s => { s.activeError = errorPromptID; return s })
}
})
console.error(graphToGraphVis(workflow.graph)) console.error(graphToGraphVis(workflow.graph))
console.error(promptToGraphVis(p)) console.error(promptToGraphVis(p))
console.error("Error queuing prompt", error, num, p) console.error("Error queuing prompt", error, num, p)
}
else if (errorMes != null) {
break; break;
} }
@@ -1256,7 +1296,7 @@ export default class ComfyApp {
let defaultValue = null; let defaultValue = null;
if (foundInput != null) { if (foundInput != null) {
const comfyInput = foundInput as IComfyInputSlot; const comfyInput = foundInput as IComfyInputSlot;
console.warn("[refreshComboInNodes] found frontend config:", node.title, node.type, comfyInput.config.values) console.warn("[refreshComboInNodes] found frontend config:", node.title, node.type, comfyInput.config.values.length)
values = comfyInput.config.values; values = comfyInput.config.values;
defaultValue = comfyInput.config.defaultValue; defaultValue = comfyInput.config.defaultValue;
} }

View File

@@ -1,3 +1,7 @@
<script context="module" lang="ts">
export const WORKFLOWS_VIEW: any = {}
</script>
<script lang="ts"> <script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes'; import { Pane, Splitpanes } from 'svelte-splitpanes';
import { PlusSquareDotted } from 'svelte-bootstrap-icons'; import { PlusSquareDotted } from 'svelte-bootstrap-icons';
@@ -12,12 +16,15 @@
import workflowState, { ComfyBoxWorkflow, type WorkflowInstID } from "$lib/stores/workflowState"; import workflowState, { ComfyBoxWorkflow, type WorkflowInstID } from "$lib/stores/workflowState";
import selectionState from "$lib/stores/selectionState"; import selectionState from "$lib/stores/selectionState";
import type ComfyApp from './ComfyApp'; import type ComfyApp from './ComfyApp';
import { onMount } from "svelte"; import { onMount, setContext, tick } from "svelte";
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action'; import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { cubicIn } from 'svelte/easing'; import { cubicIn } from 'svelte/easing';
import { truncateString } from '$lib/utils'; import { truncateString } from '$lib/utils';
import ComfyPaneView from './ComfyPaneView.svelte'; import ComfyPaneView from './ComfyPaneView.svelte';
import type { PromptID } from '$lib/api';
import queueState, { type CompletedQueueEntry } from '$lib/stores/queueState';
import { workflowErrorToGraphErrors } from '$lib/apiErrors';
export let app: ComfyApp; export let app: ComfyApp;
export let uiTheme: string = "gradio-dark" // TODO config export let uiTheme: string = "gradio-dark" // TODO config
@@ -44,6 +51,8 @@
} }
); );
refreshView(); refreshView();
lastError = null;
}) })
async function doRefreshCombos() { async function doRefreshCombos() {
@@ -70,18 +79,22 @@
let graphSize = 0; let graphSize = 0;
$: if (containerElem) { function getGraphPane(): HTMLDivElement | null {
const canvas = containerElem.querySelector<HTMLDivElement>("#graph-canvas") const canvas = containerElem.querySelector<HTMLDivElement>("#graph-canvas")
if (canvas) { if (!canvas)
const paneNode = canvas.closest(".splitpanes__pane") return null;
if (paneNode) { return canvas.closest(".splitpanes__pane")
(paneNode as HTMLElement).ontransitionstart = () => { }
$interfaceState.graphTransitioning = true
} $: if (containerElem) {
(paneNode as HTMLElement).ontransitionend = () => { const paneNode = getGraphPane();
$interfaceState.graphTransitioning = false if (paneNode) {
app.resizeCanvas() (paneNode as HTMLElement).ontransitionstart = () => {
} $interfaceState.graphTransitioning = true
}
(paneNode as HTMLElement).ontransitionend = () => {
$interfaceState.graphTransitioning = false
app.resizeCanvas()
} }
} }
} }
@@ -191,6 +204,77 @@
return s; return s;
}) })
}; };
let lastError: PromptID | null = null;
$: {
const activeError = $uiState.activeError
if (activeError != lastError) {
if (activeError != null) {
showError(activeError)
}
else {
hideError();
}
lastError = $uiState.activeError;
}
}
async function showError(promptIDWithError: PromptID) {
hideError();
const completed: CompletedQueueEntry = get($queueState.queueCompleted).find(e => e.entry.promptID === promptIDWithError);
if (!completed || !completed.error) {
console.error("Prompt with error not found!", promptIDWithError);
return
}
const workflow = workflowState.getWorkflow(completed.entry.extraData.workflowID)
if (workflow == null) {
console.error("Workflow with error not found!", promptIDWithError, completed);
return
}
workflowState.setActiveWorkflow(app.lCanvas, workflow.id);
$uiState.activeError = promptIDWithError;
lastError = $uiState.activeError;
const jumpToError = () => {
app.resizeCanvas();
app.lCanvas.draw(true, true);
app.lCanvas.activeErrors = workflowErrorToGraphErrors(workflow.id, completed.error, completed.entry);
app.lCanvas.jumpToFirstError();
}
const newGraphSize = Math.max(50, graphSize);
const willOpenPane = newGraphSize != graphSize
graphSize = newGraphSize
if (willOpenPane) {
const graphPane = getGraphPane();
if (graphPane) {
graphPane.addEventListener("transitionend", jumpToError, { once: true })
await tick()
}
else {
jumpToError()
}
}
else {
jumpToError()
}
}
function hideError() {
if (app?.lCanvas) {
app.lCanvas.clearErrors();
}
}
setContext(WORKFLOWS_VIEW, {
showError
});
</script> </script>
<div id="comfy-content" bind:this={containerElem} class:loading> <div id="comfy-content" bind:this={containerElem} class:loading>

View File

@@ -0,0 +1,299 @@
<script lang="ts">
import { ComfyNodeErrorType, type ComfyGraphErrorLocation, type ComfyGraphErrors } from "$lib/apiErrors";
import type ComfyApp from "./ComfyApp";
import Accordion from "./gradio/app/Accordion.svelte";
import uiState from '$lib/stores/uiState';
import type { ComfyNodeDefInputType } from "$lib/ComfyNodeDef";
import type { INodeInputSlot, LGraphNode, Subgraph } from "@litegraph-ts/core";
import { UpstreamNodeLocator } from "./ComfyPromptSerializer";
import JsonView from "./JsonView.svelte";
export let app: ComfyApp;
export let errors: ComfyGraphErrors;
function closeList() {
app.lCanvas.clearErrors();
$uiState.activeError = null;
}
function getParentNode(error: ComfyGraphErrorLocation): Subgraph | null {
const node = app.lCanvas.graph.getNodeByIdRecursive(error.nodeID);
if (node == null || !node.graph._is_subgraph)
return null;
return node.graph._subgraph_node
}
function canJumpToDisconnectedInput(error: ComfyGraphErrorLocation): boolean {
return error.errorType === ComfyNodeErrorType.RequiredInputMissing && error.input != null;
}
function jumpToDisconnectedInput(error: ComfyGraphErrorLocation) {
if (error.errorType !== ComfyNodeErrorType.RequiredInputMissing || error.input == null) {
return
}
const node = app.lCanvas.graph.getNodeByIdRecursive(error.nodeID);
const inputIndex =node.findInputSlotIndexByName(error.input.name);
if (inputIndex === -1) {
return
}
// TODO multiple tags?
const tag: string | null = error.queueEntry.extraData.extra_pnginfo.comfyBoxPrompt.subgraphs[0];
const test = (node: LGraphNode) => (node as any).isBackendNode
const nodeLocator = new UpstreamNodeLocator(test)
const [_, foundLink, foundInputSlot, foundPrevNode] = nodeLocator.locateUpstream(node, inputIndex, tag);
if (foundInputSlot != null && foundPrevNode != null) {
app.lCanvas.jumpToNodeAndInput(foundPrevNode, foundInputSlot);
}
}
function jumpToError(error: ComfyGraphErrorLocation) {
app.lCanvas.jumpToError(error);
}
function getInputTypeName(type: ComfyNodeDefInputType) {
if (Array.isArray(type)) {
return `List (${type.length})`
}
return type;
}
</script>
<div class="error-list">
<div class="error-list-header">
<button class="error-list-close" on:click={closeList}>✕</button>
</div>
{#each Object.entries(errors.errorsByID) as [nodeID, nodeErrors]}
{@const first = nodeErrors[0]}
{@const parent = getParentNode(first)}
<div class="error-group">
<div class="error-node-details">
<span class="error-node-type">{first.comfyNodeType}</span>
{#if parent}
<span class="error-node-parent">({parent.title})</span>
{/if}
</div>
<div class="error-entries">
{#each nodeErrors as error}
{@const isExecutionError = error.errorType === "execution"}
<div class="error-entry">
<div>
<div class="error-details">
<button class="jump-to-error" class:execution-error={isExecutionError} on:click={() => jumpToError(error)}><span></span></button>
<div class="error-details-wrapper">
<span class="error-message" class:execution-error={isExecutionError}>{error.message}</span>
{#if error.exceptionType}
<span>({error.exceptionType})</span>
{/if}
{#if error.exceptionMessage && !isExecutionError}
<div style:text-decoration="underline">{error.exceptionMessage}</div>
{/if}
{#if error.input}
<div class="error-input">
<span>Input: <b>{error.input.name}</b></span>
{#if error.input.config}
<span>({getInputTypeName(error.input.config[0])})</span>
{/if}
</div>
{#if canJumpToDisconnectedInput(error)}
<div style:display="flex" style:flex-direction="row">
<button class="jump-to-error locate" on:click={() => jumpToDisconnectedInput(error)}><span></span></button>
<span>Find disconnected input</span>
</div>
{/if}
{#if error.input.receivedValue}
<div>
<span>Received value: <b>{error.input.receivedValue}</b></span>
</div>
{/if}
{#if error.input.receivedType}
<div>
<span>Received type: <b>{error.input.receivedType}</b></span>
</div>
{/if}
{#if error.input.config}
<div class="error-traceback-wrapper">
<Accordion label="Input Config" open={true}>
<div class="error-traceback">
<div class="error-traceback-contents">
<JsonView json={error.input.config[1]} />
</div>
</div>
</Accordion>
</div>
{/if}
{/if}
</div>
</div>
</div>
{#if error.traceback}
<div class="error-traceback-wrapper">
<Accordion label="Traceback" open={false}>
<div class="error-traceback">
<div class="error-traceback-contents">
{#each error.traceback as line}
<pre>{line}</pre>
{/each}
</div>
</div>
</Accordion>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
<style lang="scss">
.error-list {
width: 30%;
height: 70%;
margin: 1.0rem;
overflow-y: auto;
position: absolute;
right: 0;
bottom: 0;
border: 1px solid #aaa;
color: #ddd;
background: #444;
font-size: 12pt;
}
.error-list-header {
width: 100%;
height: 24px;
margin: auto;
border-bottom: 1px solid #ccc;
background: #282828;
justify-content: center;
text-align: center;
.error-list-close {
margin: auto;
padding-right: 6px;
position: absolute;
top: 0;
right: 0;
}
}
.error-node-details {
font-size: 14pt;
color: #ddd;
font-weight: bold;
padding: 0.7rem 1.0rem;
background: #333;
}
.error-node-parent {
color: #aaa;
font-size: 12pt;
font-weight: initial;
}
.error-entries:last-child {
border-bottom: 1px solid #ccc;
}
.error-entry {
opacity: 100%;
border-top: 1px solid #ccc;
padding: 1rem;
}
.error-details {
width: 100%;
display: flex;
flex-direction: row;
gap: var(--spacing-md);
vertical-align: bottom;
position: relative;
> span {
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
}
.error-details-wrapper {
flex: 5 1 0%;
}
.error-message {
color: #F66;
&.execution-error {
color: #E6E;
}
text-decoration: underline;
}
.error-input {
font-size: 12pt;
}
.jump-to-error {
border: 1px solid #ccc;
background: #844;
&.execution-error {
background: #848;
}
&.locate {
background: #488;
}
width: 32px;
height: 32px;
font-size: 14pt;
text-align: center;
display: flex;
position: relative;
justify-content: center;
margin-right: 0.3rem;
> span {
margin: auto;
}
&:hover {
filter: brightness(120%);
}
&:active {
filter: brightness(80%);
}
}
.error-traceback-wrapper {
width: 100%;
margin-top: 1.0rem;
padding: 0.5rem;
border: 1px solid #888;
.error-traceback {
font-size: 10pt;
overflow: auto;
white-space: nowrap;
background: #333;
.error-traceback-contents {
width: 100%;
font-family: monospace !important;
padding: 1.0rem;
> div {
width: 100%;
}
}
}
}
</style>

View File

@@ -1,28 +1,48 @@
<script lang="ts"> <script lang="ts">
import { Button } from "@gradio/button"; import { onMount } from "svelte";
import type ComfyApp from "./ComfyApp"; import type ComfyApp from "./ComfyApp";
import DropZone from "./DropZone.svelte"; import DropZone from "./DropZone.svelte";
import interfaceState from "$lib/stores/interfaceState"; import interfaceState from "$lib/stores/interfaceState";
import workflowState from "$lib/stores/workflowState";
import uiState from '$lib/stores/uiState';
import ComfyGraphErrorList from "$lib/components/ComfyGraphErrorList.svelte"
export let app: ComfyApp; export let app: ComfyApp;
let canvas: HTMLCanvasElement;
onMount(async () => {
if (app?.lCanvas) {
canvas = app.lCanvas.canvas;
app.lCanvas?.setCanvas(canvas)
}
})
function doRecenter(): void { function doRecenter(): void {
app?.lCanvas?.recenter(); app?.lCanvas?.recenter();
} }
function clearErrors(): void {
$uiState.activeError = null;
}
</script> </script>
<div class="wrapper litegraph"> <div class="wrapper litegraph">
<div class="canvas-wrapper pane-wrapper"> <div class="canvas-wrapper pane-wrapper">
<canvas id="graph-canvas" /> <canvas bind:this={canvas} id="graph-canvas" />
<DropZone {app} /> <DropZone {app} />
</div> </div>
<div class="bar"> <div class="bar">
{#if !$interfaceState.graphTransitioning} <span class="left">
<span class="left"> <button disabled={$interfaceState.graphTransitioning} on:click={doRecenter}>Recenter</button>
<button on:click={doRecenter}>Recenter</button> {#if $uiState.activeError != null}
</span> <button disabled={$interfaceState.graphTransitioning} on:click={clearErrors}>Clear Errors</button>
{/if} {/if}
</span>
</div> </div>
{#if $uiState.activeError && app?.lCanvas?.activeErrors != null}
<ComfyGraphErrorList {app} errors={app.lCanvas.activeErrors} />
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -73,6 +93,9 @@
background-color: #555; background-color: #555;
border-color: #777; border-color: #777;
} }
&:disabled {
opacity: 50%;
}
} }
} }
</style> </style>

View File

@@ -15,18 +15,17 @@ function isGraphInputOutput(node: LGraphNode): boolean {
return node.is(GraphInput) || node.is(GraphOutput) return node.is(GraphInput) || node.is(GraphOutput)
} }
export function nodeHasTag(node: LGraphNode, tag: string): boolean { export function nodeHasTag(node: LGraphNode, tag: string, checkParents: boolean): boolean {
// Ignore tags on reroutes since they're just movable wires and it defeats
// the convenience gains to have to set tags for all them
if (isReroute(node))
return true;
while (node != null) { while (node != null) {
if ("tags" in node.properties) { if ("tags" in node.properties) {
if (node.properties.tags.indexOf(tag) !== -1) if (node.properties.tags.indexOf(tag) !== -1)
return true; return true;
} }
if (!checkParents) {
return false;
}
// Count parent subgraphs having the tag also. // Count parent subgraphs having the tag also.
node = node.graph?._subgraph_node; node = node.graph?._subgraph_node;
} }
@@ -38,8 +37,10 @@ export function isActiveNode(node: LGraphNode, tag: string | null = null): boole
if (!node) if (!node)
return false; return false;
// Check tags but not on graph inputs/outputs // Ignore tags on reroutes since they're just movable wires and it defeats
if (!isGraphInputOutput(node) && (tag && !nodeHasTag(node, tag))) { // the convenience gains to have to set tags for all them
// Also ignore graph inputs/outputs
if (!isReroute(node) && !isGraphInputOutput(node) && (tag && !nodeHasTag(node, tag, true))) {
console.debug("Skipping tagged node", tag, node.properties.tags, node) console.debug("Skipping tagged node", tag, node.properties.tags, node)
return false; return false;
} }
@@ -70,11 +71,13 @@ export function isActiveBackendNode(node: LGraphNode, tag: string | null = null)
return true; return true;
} }
type UpstreamResult = [LGraph | null, LLink | null, number | null, LGraphNode | null];
export class UpstreamNodeLocator { export class UpstreamNodeLocator {
constructor(private isTheTargetNode: (node: LGraphNode) => boolean) { constructor(private isTheTargetNode: (node: LGraphNode) => boolean) {
} }
private followSubgraph(subgraph: Subgraph, link: LLink): [LGraph | null, LLink | null] { private followSubgraph(subgraph: Subgraph, link: LLink): UpstreamResult {
if (link.origin_id != subgraph.id) if (link.origin_id != subgraph.id)
throw new Error("Invalid link and graph output!") throw new Error("Invalid link and graph output!")
@@ -83,10 +86,10 @@ export class UpstreamNodeLocator {
throw new Error("No inner graph input!") throw new Error("No inner graph input!")
const nextLink = innerGraphOutput.getInputLink(0) const nextLink = innerGraphOutput.getInputLink(0)
return [innerGraphOutput.graph, nextLink]; return [innerGraphOutput.graph, nextLink, 0, innerGraphOutput];
} }
private followGraphInput(graphInput: GraphInput, link: LLink): [LGraph | null, LLink | null] { private followGraphInput(graphInput: GraphInput, link: LLink): UpstreamResult {
if (link.origin_id != graphInput.id) if (link.origin_id != graphInput.id)
throw new Error("Invalid link and graph input!") throw new Error("Invalid link and graph input!")
@@ -99,10 +102,10 @@ export class UpstreamNodeLocator {
throw new Error("No outer input slot!") throw new Error("No outer input slot!")
const nextLink = outerSubgraph.getInputLink(outerInputIndex) const nextLink = outerSubgraph.getInputLink(outerInputIndex)
return [outerSubgraph.graph, nextLink]; return [outerSubgraph.graph, nextLink, outerInputIndex, outerSubgraph];
} }
private getUpstreamLink(parent: LGraphNode, currentLink: LLink): [LGraph | null, LLink | null] { private getUpstreamLink(parent: LGraphNode, currentLink: LLink): UpstreamResult {
if (parent.is(Subgraph)) { if (parent.is(Subgraph)) {
console.debug("FollowSubgraph") console.debug("FollowSubgraph")
return this.followSubgraph(parent, currentLink); return this.followSubgraph(parent, currentLink);
@@ -112,17 +115,18 @@ export class UpstreamNodeLocator {
return this.followGraphInput(parent, currentLink); return this.followGraphInput(parent, currentLink);
} }
else if ("getUpstreamLink" in parent) { else if ("getUpstreamLink" in parent) {
return [parent.graph, (parent as ComfyGraphNode).getUpstreamLink()]; const link = (parent as ComfyGraphNode).getUpstreamLink();
return [parent.graph, link, link?.target_slot, parent];
} }
else if (parent.inputs.length === 1) { else if (parent.inputs.length === 1) {
// Only one input, so assume we can follow it backwards. // Only one input, so assume we can follow it backwards.
const link = parent.getInputLink(0); const link = parent.getInputLink(0);
if (link) { if (link) {
return [parent.graph, link] return [parent.graph, link, 0, parent]
} }
} }
console.warn("[graphToPrompt] Frontend node does not support getUpstreamLink", parent.type) console.warn("[graphToPrompt] Frontend node does not support getUpstreamLink", parent.type)
return [null, null]; return [null, null, null, null];
} }
/* /*
@@ -132,13 +136,15 @@ export class UpstreamNodeLocator {
* Returns the node and the output link attached to it that leads to the * Returns the node and the output link attached to it that leads to the
* starting node if any. * starting node if any.
*/ */
locateUpstream(fromNode: LGraphNode, inputIndex: SlotIndex, tag: string | null): [LGraphNode | null, LLink | null] { locateUpstream(fromNode: LGraphNode, inputIndex: SlotIndex, tag: string | null): [LGraphNode | null, LLink | null, number | null, LGraphNode | null] {
let parent = fromNode.getInputNode(inputIndex); let parent = fromNode.getInputNode(inputIndex);
if (!parent) if (!parent)
return [null, null]; return [null, null, null, null];
const seen = {} const seen = {}
let currentLink = fromNode.getInputLink(inputIndex); let currentLink = fromNode.getInputLink(inputIndex);
let currentInputSlot = inputIndex;
let currentNode = fromNode;
const shouldFollowParent = (parent: LGraphNode) => { const shouldFollowParent = (parent: LGraphNode) => {
return isActiveNode(parent, tag) && !this.isTheTargetNode(parent); return isActiveNode(parent, tag) && !this.isTheTargetNode(parent);
@@ -151,7 +157,10 @@ export class UpstreamNodeLocator {
// nodes have conditional logic that determines which link // nodes have conditional logic that determines which link
// to follow backwards. // to follow backwards.
while (shouldFollowParent(parent)) { while (shouldFollowParent(parent)) {
const [nextGraph, nextLink] = this.getUpstreamLink(parent, currentLink); const [nextGraph, nextLink, nextInputSlot, nextNode] = this.getUpstreamLink(parent, currentLink);
currentInputSlot = nextInputSlot;
currentNode = nextNode;
if (nextLink == null) { if (nextLink == null) {
console.warn("[graphToPrompt] No upstream link found in frontend node", parent) console.warn("[graphToPrompt] No upstream link found in frontend node", parent)
@@ -175,9 +184,9 @@ export class UpstreamNodeLocator {
} }
if (!isActiveNode(parent, tag) || !this.isTheTargetNode(parent) || currentLink == null) if (!isActiveNode(parent, tag) || !this.isTheTargetNode(parent) || currentLink == null)
return [null, null]; return [null, currentLink, currentInputSlot, currentNode];
return [parent, currentLink] return [parent, currentLink, currentInputSlot, currentNode]
} }
} }

View File

@@ -8,7 +8,8 @@
date?: string, date?: string,
status: QueueUIEntryStatus, status: QueueUIEntryStatus,
images?: string[], // URLs images?: string[], // URLs
details?: string // shown in a tooltip on hover details?: string, // shown in a tooltip on hover
error?: WorkflowError
} }
</script> </script>
@@ -21,15 +22,14 @@
import { convertComfyOutputToComfyURL, convertFilenameToComfyURL, getNodeInfo, truncateString } from "$lib/utils" import { convertComfyOutputToComfyURL, convertFilenameToComfyURL, getNodeInfo, truncateString } from "$lib/utils"
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { QueueItemType } from "$lib/api"; import type { QueueItemType } from "$lib/api";
import { ImageViewer } from "$lib/ImageViewer";
import { Button } from "@gradio/button"; import { Button } from "@gradio/button";
import type ComfyApp from "./ComfyApp"; import type ComfyApp from "./ComfyApp";
import { tick } from "svelte"; import { getContext, tick } from "svelte";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import DropZone from "./DropZone.svelte"; import { type WorkflowError } from "$lib/stores/workflowState";
import workflowState from "$lib/stores/workflowState";
import ComfyQueueListDisplay from "./ComfyQueueListDisplay.svelte"; import ComfyQueueListDisplay from "./ComfyQueueListDisplay.svelte";
import ComfyQueueGridDisplay from "./ComfyQueueGridDisplay.svelte"; import ComfyQueueGridDisplay from "./ComfyQueueGridDisplay.svelte";
import { WORKFLOWS_VIEW } from "./ComfyBoxWorkflowsView.svelte";
export let app: ComfyApp; export let app: ComfyApp;
@@ -38,6 +38,8 @@
let queueCompleted: Writable<CompletedQueueEntry[]> | null = null; let queueCompleted: Writable<CompletedQueueEntry[]> | null = null;
let queueList: HTMLDivElement | null = null; let queueList: HTMLDivElement | null = null;
const { showError } = getContext(WORKFLOWS_VIEW) as any;
$: if ($queueState) { $: if ($queueState) {
queuePending = $queueState.queuePending queuePending = $queueState.queuePending
queueRunning = $queueState.queueRunning queueRunning = $queueState.queueRunning
@@ -144,8 +146,11 @@
if (entry.extraData?.workflowTitle != null) { if (entry.extraData?.workflowTitle != null) {
message = `${entry.extraData.workflowTitle}` message = `${entry.extraData.workflowTitle}`
} }
if (subgraphs?.length > 0)
message += ` (${subgraphs.join(', ')})` if (subgraphs && subgraphs.length > 0) {
const subgraphsString = subgraphs.join(', ')
message += ` (${subgraphsString})`
}
let submessage = `Nodes: ${Object.keys(entry.prompt).length}` let submessage = `Nodes: ${Object.keys(entry.prompt).length}`
@@ -197,7 +202,7 @@
else if (entry.status === "interrupted" || entry.status === "all_cached") else if (entry.status === "interrupted" || entry.status === "all_cached")
result.submessage = "Prompt was interrupted." result.submessage = "Prompt was interrupted."
if (entry.error) if (entry.error)
result.details = entry.error result.error = entry.error
return result; return result;
} }
@@ -231,10 +236,20 @@
let selectedPrompt = null; let selectedPrompt = null;
let selectedImages = []; let selectedImages = [];
function showPrompt(entry: QueueUIEntry) { function showPrompt(entry: QueueUIEntry) {
selectedPrompt = entry.entry.prompt; if (entry.error != null) {
selectedImages = entry.images; showModal = false;
showModal = true; expandAll = false;
expandAll = false selectedPrompt = null;
selectedImages = [];
showError(entry.entry.promptID);
}
else {
selectedPrompt = entry.entry.prompt;
selectedImages = entry.images;
showModal = true;
expandAll = false
}
} }
function closeModal() { function closeModal() {

View File

@@ -148,8 +148,11 @@
&.success { &.success {
/* background: green; */ /* background: green; */
} }
&.validation_failed {
background: #551a1a;
}
&.error { &.error {
background: red; background: #401a40;
} }
&.all_cached, &.interrupted { &.all_cached, &.interrupted {
filter: brightness(80%); filter: brightness(80%);

View File

@@ -3,7 +3,7 @@
import type { ComfyImageLocation } from "$lib/nodes/ComfyWidgetNodes"; import type { ComfyImageLocation } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify"; import notify from "$lib/notify";
import configState from "$lib/stores/configState"; import configState from "$lib/stores/configState";
import { convertComfyOutputEntryToGradio, convertComfyOutputToComfyURL, type ComfyUploadImageAPIResponse } from "$lib/utils"; import { batchUploadFilesToComfyUI, convertComfyOutputToComfyURL, type ComfyBatchUploadResult } from "$lib/utils";
import { Block, BlockLabel } from "@gradio/atoms"; import { Block, BlockLabel } from "@gradio/atoms";
import { File as FileIcon } from "@gradio/icons"; import { File as FileIcon } from "@gradio/icons";
import type { FileData as GradioFileData } from "@gradio/upload"; import type { FileData as GradioFileData } from "@gradio/upload";
@@ -59,55 +59,10 @@
dispatch("image_clicked") dispatch("image_clicked")
} }
interface GradioUploadResponse { async function upload_files(files: Array<File>): Promise<ComfyBatchUploadResult> {
error?: string; console.debug("UPLOADFILES", files);
files?: Array<ComfyImageLocation>;
}
async function upload_files(root: string, files: Array<File>): Promise<GradioUploadResponse> {
console.debug("UPLOADILFES", root, files);
dispatch("uploading") dispatch("uploading")
return batchUploadFilesToComfyUI(files);
const url = configState.getBackendURL();
const requests = files.map(async (file) => {
const formData = new FormData();
formData.append("image", file, file.name);
return fetch(new Request(url + "/upload/image", {
body: formData,
method: 'POST'
}))
.then(r => r.json())
.catch(error => error);
});
return Promise.all(requests)
.then( (results) => {
const errors = []
const files = []
for (const r of results) {
if (r instanceof Error) {
errors.push(r.toString())
}
else {
// bare filename of image
const resp = r as ComfyUploadImageAPIResponse;
files.push({
filename: resp.name,
subfolder: "",
type: "input"
})
}
}
let error = null;
if (errors && errors.length > 0)
error = "Upload error(s):\n" + errors.join("\n");
return { error, files }
})
} }
$: { $: {
@@ -144,7 +99,7 @@
); );
let upload_value = _value; let upload_value = _value;
pending_upload = true; pending_upload = true;
upload_files(root, files).then((response) => { upload_files(files).then((response) => {
if (JSON.stringify(upload_value) !== JSON.stringify(_value)) { if (JSON.stringify(upload_value) !== JSON.stringify(_value)) {
// value has changed since upload started // value has changed since upload started
console.error("[ImageUpload] value has changed since upload started", upload_value, _value) console.error("[ImageUpload] value has changed since upload started", upload_value, _value)

View File

@@ -0,0 +1,669 @@
<script context="module" lang="ts">
export type MaskCanvasData = {
hasMask: boolean,
maskCanvas: HTMLCanvasElement | null,
curLineGroup: LineGroup,
redoCurLines: LineGroup,
}
export type LinePoint = {
x: number,
y: number
}
export interface Line {
size?: number,
points: LinePoint[]
}
export type LineGroup = Line[];
</script>
<script lang="ts">
import { loadImage } from "$lib/widgets/utils"
import { tick, createEventDispatcher } from "svelte";
import { ArrowClockwise, ArrowCounterclockwise, XSquare, Exclude, Circle, Grid3x3Gap, ArrowsFullscreen, FullscreenExit } from "svelte-bootstrap-icons";
export let fileURL: string | null = null;
export let fullscreen: boolean = false;
const dispatch = createEventDispatcher<{
change: MaskCanvasData;
release: MaskCanvasData;
loaded: MaskCanvasData
}>();
let canvasCursor: string | undefined = undefined;
let container: HTMLDivElement | null;
let canvas: HTMLCanvasElement | null;
let maskCanvas: HTMLCanvasElement | null;
let renders: HTMLImageElement[] = [];
let context: CanvasRenderingContext2D | null;
let maskContext: CanvasRenderingContext2D | null;
let curLineGroup: LineGroup = [];
let redoCurLines: LineGroup = []
let original: HTMLImageElement | null;
let isImageLoaded: boolean = false;
let imageWidth: number = 512;
let imageHeight: number = 512;
let scale: number = 1.0;
let minScale: number = 1.0;
let brushSize: number = 100;
let maskBlur: number = 0;
let clipMask: boolean = false;
let hasMask: boolean = false;
let isDrawing: boolean = false;
let isPanning: boolean = false;
let isBrushShowing: boolean = false;
let transform = ""
$: transform = `translate(${imx}px, ${imy}px) scale(${scale})`
let imx: number = 0;
let imy: number = 0;
let x: number = 0;
let y: number = 0;
let panX: number = 0;
let panY: number = 0;
const BRUSH_COLOR = "#000"
enum MouseButton {
Left = 0,
Middle = 1,
Right = 2,
Back = 3,
Forward = 4
}
$: if (isPanning) {
canvasCursor = "grab";
}
else if (isImageLoaded && isBrushShowing) {
canvasCursor = "none";
}
else {
canvasCursor = undefined;
}
$: {
context = canvas ? canvas.getContext("2d") : null
}
function clearState() {
hasMask = false;
maskCanvas = null;
maskContext = null;
isImageLoaded = false;
original = null;
renders = []
// curLineGroup = [];
// redoCurLines = []
imageWidth = 512;
imageHeight = 512;
scale = 1.0;
minScale = 0.5;
}
let loadedFileURL: string | null = null
let dispatchLoaded: boolean = false;
$: if (fileURL !== loadedFileURL) {
clearState();
if (fileURL) {
loadImage(fileURL).then(i => {
original = i;
isImageLoaded = true;
dispatchLoaded = true;
})
.catch(i => {
isImageLoaded = false;
})
}
else {
isImageLoaded = false;
original = null;
}
loadedFileURL = fileURL
}
$: {
// initSizeAndScale(isImageLoaded, original);
[imageWidth, imageHeight] = getCurrentWidthAndHeight(isImageLoaded, original)
scale = initScale(imageWidth, imageHeight)
initImagePos()
// in case mask strokes were preserved after new image load
// (use case: sending an inpainted image back while reusing the same mask)
tick().then(() => {
loaded();
})
}
function loaded() {
if (!dispatchLoaded)
return;
dispatchLoaded = false;
redrawCurLines()
hasMask = curLineGroup.length > 0;
console.warn("[MaskCanvas] LOADED", maskCanvas, hasMask)
dispatch("loaded", {
hasMask,
maskCanvas,
curLineGroup,
redoCurLines
})
}
$: hasMask = curLineGroup.length > 0;
function initImagePos() {
if (!container)
return
const rect = container.getBoundingClientRect();
imx = rect.width / 2 - (imageWidth / 2) * scale
imy = rect.height / 2 - (imageHeight / 2) * scale
}
function initScale(width: number, height: number): number {
const s = getScale(width, height);
minScale = s / 2;
return s;
}
function initSizeAndScale(isImageLoaded: boolean, original: HTMLImageElement | null) {
[imageWidth, imageHeight] = getCurrentWidthAndHeight(isImageLoaded, original)
scale = getScale(imageWidth, imageHeight)
minScale = scale;
}
function drawOnCurrentRender(lineGroup: LineGroup) {
draw(lineGroup)
dispatch("change", {
hasMask,
maskCanvas,
curLineGroup,
redoCurLines
})
}
function draw(lineGroup: LineGroup) {
if (!context || !maskContext)
return
context.clearRect(0, 0, context.canvas.width, context.canvas.height)
maskContext.clearRect(0, 0, maskContext.canvas.width, maskContext.canvas.height)
const color = BRUSH_COLOR
const drawMask = (ctx: CanvasRenderingContext2D) => {
ctx.save();
ctx.filter = `blur(${maskBlur}px)`
drawLines(ctx, lineGroup, color)
ctx.restore();
}
drawMask(maskContext);
if (clipMask) {
context.save();
context.filter = `blur(${maskBlur}px)`
drawLines(context, lineGroup, color)
context.restore();
context.globalCompositeOperation = "source-in"
context.drawImage(original!!, 0, 0, imageWidth, imageHeight)
context.globalCompositeOperation = "source-over";
}
else {
drawMask(context);
}
}
function updateMaskImage() {
drawOnCurrentRender(curLineGroup);
}
function drawLines(ctx: CanvasRenderingContext2D, lines: LineGroup, color: string) {
ctx.strokeStyle = color
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
lines.forEach(line => {
if (!line?.points.length || !line.size) {
return
}
ctx.lineWidth = line.size
ctx.beginPath()
ctx.moveTo(line.points[0].x, line.points[0].y)
line.points.forEach(point => ctx.lineTo(point.x, point.y))
ctx.stroke()
})
}
function redrawCurLines() {
drawOnCurrentRender(curLineGroup || [])
}
$: if (canvas && original) {
console.warn("INITCANVAS", imageWidth, imageHeight, original.src)
maskCanvas = document.createElement("canvas");
maskContext = maskCanvas.getContext("2d")!;
maskCanvas.width = imageWidth;
maskCanvas.height = imageHeight;
canvas.width = imageWidth;
canvas.height = imageHeight;
redrawCurLines() // no react on curLineGroup
}
function getCurrentWidthAndHeight(isImageLoaded: boolean, original: HTMLImageElement | null) {
if (isImageLoaded && original){
return [original.naturalWidth, original.naturalHeight]
}
return [512, 512]
}
function getScale(width: number, height: number): number {
const size = container?.getBoundingClientRect();
if (!size) {
return 1.0
}
const ratioWidth = size.width / width
const ratioHeight = (size.height) / height
let scale: number = 1.0
if (ratioWidth < 1 || ratioHeight < 1) {
scale = Math.min(ratioWidth, ratioHeight)
}
return scale
}
function undoStroke() {
if (curLineGroup.length === 0) {
return
}
const lastLine = curLineGroup.pop()!
const newRedoCurLines = [...redoCurLines, lastLine]
redoCurLines = newRedoCurLines
const newLineGroup = [...curLineGroup]
curLineGroup = newLineGroup
drawOnCurrentRender(newLineGroup)
}
function redoStroke() {
if (redoCurLines.length === 0) {
return
}
const line = redoCurLines.pop()!
redoCurLines = [...redoCurLines]
const newLineGroup = [...curLineGroup, line]
curLineGroup = newLineGroup
drawOnCurrentRender(newLineGroup)
}
export function clearStrokes() {
redoCurLines = []
const newLineGroup: LineGroup = []
curLineGroup = newLineGroup
drawOnCurrentRender(newLineGroup)
}
export function recenterImage() {
scale = initScale(imageWidth, imageHeight)
initImagePos();
}
async function toggleFullscreen() {
fullscreen = !fullscreen;
updateMaskImage();
await tick();
recenterImage();
}
function onCanvasMouseOver() {
isBrushShowing = true;
}
function onCanvasFocus() {
isBrushShowing = true;
}
function onCanvasMouseLeave() {
isBrushShowing = false;
}
function mouseXY(e: MouseEvent): LinePoint {
return { x: e.offsetX, y: e.offsetY }
}
function onCanvasMouseDown(e: MouseEvent) {
if (!original?.src)
return;
if (isPanning)
return;
if (canvas == null)
return;
switch (e.button) {
case MouseButton.Right:
return;
case MouseButton.Middle:
isPanning = true;
panX = e.offsetX * scale;
panY = e.offsetY * scale;
return;
}
isDrawing = true;
redoCurLines = []
let lineGroup: LineGroup = [...curLineGroup]
lineGroup.push({size: brushSize, points: [mouseXY(e)] })
curLineGroup = lineGroup
drawOnCurrentRender(curLineGroup);
}
function onCanvasMouseUp() {
}
function onCanvasMouseMove(e: MouseEvent) {
if (isPanning)
return;
if (!isDrawing)
return;
if (curLineGroup.length === 0)
return
curLineGroup[curLineGroup.length-1].points.push(mouseXY(e))
curLineGroup = curLineGroup; // react
drawOnCurrentRender(curLineGroup)
}
function onCanvasMouseWheel(e: WheelEvent) {
e.preventDefault();
if (!container || e.target != canvas)
return;
const bound = container.getBoundingClientRect()
// coodinates on the image that were zoomed
const x_ = e.clientX - bound.x
const y_ = e.clientY - bound.y
e.preventDefault();
var delta = e.deltaY * -0.001
delta = Math.max(-1,Math.min(1,delta)) // cap the delta to [-1,1] for cross browser consistency
const zx = (x_ - imx)/scale
const zy = (y_ - imy)/scale
scale += delta * scale
scale = Math.max(minScale,scale)
imx = -zx * scale + x_
imy = -zy * scale + y_
x = e.offsetX * scale;
y = e.offsetY * scale;
}
function onMouseMove(e: MouseEvent) {
if (e.target != canvas)
return;
x = e.offsetX * scale;
y = e.offsetY * scale;
if (isPanning) {
imx += x - panX;
imy += y - panY;
}
}
function onMouseUp(e: MouseEvent) {
if (e.button === MouseButton.Middle) {
isPanning = false
panX = 0
panY = 0
}
if (isPanning)
return;
if (!original?.src)
return;
if (!canvas)
return;
if (!isDrawing)
return;
isDrawing = false;
dispatch("release", { hasMask, maskCanvas, curLineGroup, redoCurLines })
}
function dispatchRelease() {
updateMaskImage()
dispatch("release", { hasMask, maskCanvas, curLineGroup, redoCurLines })
}
</script>
<svelte:window on:mouseup={onMouseUp} />
<div class="me-container" class:fullscreen bind:this={container} on:mousemove={onMouseMove}>
{#if !isImageLoaded}
<div>
(empty)
</div>
{:else}
<div class="me-transform" style:transform={transform} style:--scale={scale}>
<div class="me-canvas-container">
<div class="me-original-image-container"
style:width="{imageWidth}px"
style:height="{imageHeight}px">
{#if original}
{@const showOriginal = !clipMask}
<img class="me-original-image"
src={original.src}
style:width={imageWidth}
style:height={imageHeight}
style:display={showOriginal ? "block" : "none"}
/>
{/if}
</div>
<canvas class="me-canvas"
bind:this={canvas}
style:cursor={canvasCursor}
on:mouseover={onCanvasMouseOver}
on:focus={onCanvasFocus}
on:wheel={onCanvasMouseWheel}
on:mouseleave={onCanvasMouseLeave}
on:mousedown|preventDefault={onCanvasMouseDown}
on:mouseup|preventDefault={onCanvasMouseUp}
on:mousemove={onCanvasMouseMove}
/>
</div>
</div>
{/if}
{#if isImageLoaded && isBrushShowing && !isPanning}
<div class="me-brush-cursor"
style:width="{brushSize * scale}px"
style:height="{brushSize * scale}px"
style:left="{x + imx}px"
style:top="{y + imy}px"
style:transform="translate(-50%, -50%)"
/>
{/if}
<div class="me-toolkit-bar">
<button disabled={curLineGroup.length === 0} on:click={undoStroke}>
<ArrowCounterclockwise />
</button>
<button disabled={redoCurLines.length === 0} on:click={redoStroke}>
<ArrowClockwise />
</button>
<button on:click={clearStrokes} disabled={curLineGroup.length === 0 && redoCurLines.length === 0}>
<XSquare/>
</button>
<label>
<Circle />
<input type="range" min="1" max="200" bind:value={brushSize} step="0.1"
on:change={updateMaskImage}
on:pointerup={dispatchRelease}/>
</label>
<label>
<Grid3x3Gap/>
<input type="range" min="1" max="100" bind:value={maskBlur} step="0.1"
on:change={updateMaskImage}
on:pointerup={dispatchRelease}/>
</label>
<div class="toggle-button" class:toggled={clipMask} on:click={() => {clipMask = !clipMask; updateMaskImage()}}>
<Exclude />
</div>
<div class="toggle-button" class:toggled={fullscreen} on:click={() => {toggleFullscreen()}}>
{#if fullscreen}
<FullscreenExit />
{:else}
<ArrowsFullscreen />
{/if}
</div>
</div>
</div>
<style lang="scss">
$bg-color: #a0a0a0;
.me-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background-color: white;
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(135deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(135deg, transparent 75%, #ccc 75%);
background-size:25px 25px; /* Must be a square */
background-position:0 0, 12.5px 0, 12.5px -12.5px, 0px 12.5px; /* Must be half of one side of the square */
&.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
margin: auto;
z-index: var(--layer-top);
}
}
.me-transform {
--scale: 1;
display: flex;
flex-wrap: wrap;
width: -moz-fit-content;
width: fit-content;
height: -moz-fit-content;
height: fit-content;
margin: 0;
padding: 0;
transform-origin: 0% 0%;
}
.me-original-image-container {
position: absolute;
top: 0;
left: 0;
grid-area: editor-content;
pointer-events: none;
user-select: none;
display: grid;
grid-template-areas: 'original-image-content';
border: calc((1 / var(--scale)) * 5px) dashed grey;
img.me-original-image {
grid-area: original-image-content;
}
}
.me-canvas {
position: absolute;
z-index: 1;
}
.me-brush-cursor {
position: absolute;
border-radius: 50%;
background-color: #000;
border: 1px solid var(--yellow-accent);
pointer-events: none;
}
.me-toolkit-bar {
position: absolute;
bottom: 0.5rem;
border-radius: 3rem;
padding: 0.4rem 24px;
display: flex;
margin: 0.5rem auto;
gap: 16px;
left: 0;
right: 0;
width: 80%;
height: 3rem;
align-items: center;
justify-content: space-evenly;
backdrop-filter: blur(12px);
background-color: white;
animation: slideUp 0.2s ease-out;
border: var(--editor-toolkit-panel-border);
box-shadow: 0 0 0 4px #0000001a, 0 3px 16px #00000014, 0 2px 6px 1px #00000017;
label {
display: flex;
flex-direction: row;
gap: 4px;
input {
width: 5rem;
}
}
button {
&:not(:disabled) {
cursor: pointer;
}
&:hover:not(:disabled) {
color: var(--secondary-600);
}
&:disabled {
opacity: 40%;
}
}
.toggle-button {
&:hover:not(:disabled) {
color: var(--secondary-600);
}
&:not(:disabled) {
cursor: pointer;
}
&.toggled {
color: var(--secondary-400);
}
}
}
</style>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { NotifyOptions } from "$lib/notify";
import { toast } from "@zerodevx/svelte-toast";
export let message: string = ""
export let notifyOptions: NotifyOptions
export let toastID: string
function onClick() {
if (notifyOptions.onClick) {
notifyOptions.onClick()
toast.pop(toastID)
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div on:click={onClick}>{message}</div>

View File

@@ -2,7 +2,7 @@
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import type { SelectData } from "@gradio/utils"; import type { SelectData } from "@gradio/utils";
import { BlockLabel, Empty, IconButton } from "@gradio/atoms"; import { BlockLabel, Empty, IconButton } from "@gradio/atoms";
import { Download } from "@gradio/icons"; import { Download, Clear } from "@gradio/icons";
import { get_coordinates_of_clicked_image } from "./utils"; import { get_coordinates_of_clicked_image } from "./utils";
import { Image } from "@gradio/icons"; import { Image } from "@gradio/icons";
@@ -33,13 +33,17 @@
dispatch("select", { index: coordinates, value: null }); dispatch("select", { index: coordinates, value: null });
} }
}; };
function remove() {
value = null;
}
</script> </script>
<BlockLabel {show_label} Icon={Image} label={label || "Image"} /> <BlockLabel {show_label} Icon={Image} label={label || "Image"} />
{#if value === null} {#if value === null}
<Empty size="large" unpadded_box={true}><Image /></Empty> <Empty size="large" unpadded_box={true}><Image /></Empty>
{:else} {:else}
<div class="download"> <div class="buttons">
<a <a
href={value} href={value}
target={window.__is_colab__ ? "_blank" : null} target={window.__is_colab__ ? "_blank" : null}
@@ -47,6 +51,7 @@
> >
<IconButton Icon={Download} label="Download" /> <IconButton Icon={Download} label="Download" />
</a> </a>
<IconButton Icon={Clear} label="Remove" on:click={remove} />
</div> </div>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<img src={value} alt="" class:selectable on:click={handle_click} bind:naturalWidth={imageWidth} bind:naturalHeight={imageHeight} /> <img src={value} alt="" class:selectable on:click={handle_click} bind:naturalWidth={imageWidth} bind:naturalHeight={imageHeight} />
@@ -63,9 +68,13 @@
cursor: crosshair; cursor: crosshair;
} }
.download { .buttons {
display: flex;
position: absolute; position: absolute;
top: 6px; top: var(--size-2);
right: 6px; right: var(--size-2);
justify-content: flex-end;
gap: var(--spacing-sm);
z-index: var(--layer-5);
} }
</style> </style>

View File

@@ -103,6 +103,16 @@ export default class ComfyGraphNode extends LGraphNode {
return null; return null;
} }
/*
* Traverses this node backwards in the graph in order to determine the type
* for slot type inheritance. This is used if there isn't a valid upstream
* link but the output type can be inferred otherwise (for example from
* properties or other connected inputs)
*/
getUpstreamLinkForInheritedType(): LLink | null {
return this.getUpstreamLink();
}
get layoutState(): WritableLayoutStateStore | null { get layoutState(): WritableLayoutStateStore | null {
return layoutStates.getLayoutByNode(this); return layoutStates.getLayoutByNode(this);
} }
@@ -151,7 +161,7 @@ export default class ComfyGraphNode extends LGraphNode {
while (currentNode) { while (currentNode) {
updateNodes.unshift(currentNode); updateNodes.unshift(currentNode);
const link = currentNode.getUpstreamLink(); const link = currentNode.getUpstreamLinkForInheritedType();
if (link !== null) { if (link !== null) {
const node = this.graph.getNodeById(link.origin_id) as ComfyGraphNode; const node = this.graph.getNodeById(link.origin_id) as ComfyGraphNode;
if (node.canInheritSlotTypes) { if (node.canInheritSlotTypes) {

View File

@@ -1,6 +1,7 @@
import { BuiltInSlotType, LiteGraph, NodeMode, type INodeInputSlot, type SlotLayout, type INodeOutputSlot, LLink, LConnectionKind, type ITextWidget, type SerializedLGraphNode, type IComboWidget } from "@litegraph-ts/core"; import { BuiltInSlotType, LiteGraph, NodeMode, type INodeInputSlot, type SlotLayout, type INodeOutputSlot, LLink, LConnectionKind, type ITextWidget, type SerializedLGraphNode, type IComboWidget } from "@litegraph-ts/core";
import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode"; import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode";
import { Watch } from "@litegraph-ts/nodes-basic"; import { Watch } from "@litegraph-ts/nodes-basic";
import { nextLetter } from "$lib/utils";
export type PickFirstMode = "anyActiveLink" | "truthy" | "dataNonNull" export type PickFirstMode = "anyActiveLink" | "truthy" | "dataNonNull"
@@ -8,17 +9,6 @@ export interface ComfyPickFirstNodeProperties extends ComfyGraphNodeProperties {
mode: PickFirstMode mode: PickFirstMode
} }
function nextLetter(s: string): string {
return s.replace(/([a-zA-Z])[^a-zA-Z]*$/, function(a) {
var c = a.charCodeAt(0);
switch (c) {
case 90: return 'A';
case 122: return 'a';
default: return String.fromCharCode(++c);
}
});
}
export default class ComfyPickFirstNode extends ComfyGraphNode { export default class ComfyPickFirstNode extends ComfyGraphNode {
override properties: ComfyPickFirstNodeProperties = { override properties: ComfyPickFirstNodeProperties = {
tags: [], tags: [],

View File

@@ -1,8 +1,17 @@
import { LiteGraph, type ITextWidget, type SlotLayout, type INumberWidget } from "@litegraph-ts/core"; import { LiteGraph, type ITextWidget, type SlotLayout, type INumberWidget } from "@litegraph-ts/core";
import ComfyGraphNode from "./ComfyGraphNode"; import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode";
import { comfyFileToAnnotatedFilepath, type ComfyBoxImageMetadata } from "$lib/utils"; import { comfyFileToAnnotatedFilepath, type ComfyBoxImageMetadata } from "$lib/utils";
export interface ComfyPickImageProperties extends ComfyGraphNodeProperties {
imageTagFilter: string
}
export default class ComfyPickImageNode extends ComfyGraphNode { export default class ComfyPickImageNode extends ComfyGraphNode {
override properties: ComfyPickImageProperties = {
tags: [],
imageTagFilter: ""
}
static slotLayout: SlotLayout = { static slotLayout: SlotLayout = {
inputs: [ inputs: [
{ name: "images", type: "COMFYBOX_IMAGES,COMFYBOX_IMAGE" }, { name: "images", type: "COMFYBOX_IMAGES,COMFYBOX_IMAGE" },
@@ -13,57 +22,87 @@ export default class ComfyPickImageNode extends ComfyGraphNode {
{ name: "filename", type: "string" }, { name: "filename", type: "string" },
{ name: "width", type: "number" }, { name: "width", type: "number" },
{ name: "height", type: "number" }, { name: "height", type: "number" },
{ name: "children", type: "COMFYBOX_IMAGES" },
] ]
} }
tagFilterWidget: ITextWidget;
filepathWidget: ITextWidget; filepathWidget: ITextWidget;
folderWidget: ITextWidget; folderWidget: ITextWidget;
widthWidget: INumberWidget; widthWidget: INumberWidget;
heightWidget: INumberWidget; heightWidget: INumberWidget;
tagsWidget: ITextWidget;
childrenWidget: INumberWidget;
constructor(title?: string) { constructor(title?: string) {
super(title) super(title)
this.tagFilterWidget = this.addWidget("text", "Tag Filter", this.properties.imageTagFilter, "imageTagFilter")
this.filepathWidget = this.addWidget("text", "File", "") this.filepathWidget = this.addWidget("text", "File", "")
this.filepathWidget.disabled = true;
this.folderWidget = this.addWidget("text", "Folder", "") this.folderWidget = this.addWidget("text", "Folder", "")
this.folderWidget.disabled = true;
this.widthWidget = this.addWidget("number", "Width", 0) this.widthWidget = this.addWidget("number", "Width", 0)
this.widthWidget.disabled = true;
this.heightWidget = this.addWidget("number", "Height", 0) this.heightWidget = this.addWidget("number", "Height", 0)
for (const widget of this.widgets) this.heightWidget.disabled = true;
widget.disabled = true; this.tagsWidget = this.addWidget("text", "Tags", "")
this.tagsWidget.disabled = true;
this.childrenWidget = this.addWidget("number", "# of Children", 0)
this.childrenWidget.disabled = true;
} }
_value: ComfyBoxImageMetadata[] | null = null; _value: ComfyBoxImageMetadata[] | null = null;
_image: ComfyBoxImageMetadata | null = null; _image: ComfyBoxImageMetadata | null = null;
_path: string | null = null; _path: string | null = null;
_index: number = 0; _index: number | null = null;
private setValue(value: ComfyBoxImageMetadata[] | ComfyBoxImageMetadata | null, index: number) { private setValue(value: ComfyBoxImageMetadata[] | ComfyBoxImageMetadata | null, index: number) {
if (value != null && !Array.isArray(value)) { if (value != null && !Array.isArray(value)) {
value = [value] value = [value]
index = 0; index = 0;
} }
const changed = this._value != value || this._index != index;
this._value = value as ComfyBoxImageMetadata[]; this._value = value as ComfyBoxImageMetadata[];
this._index = index; this._index = index;
let image: ComfyBoxImageMetadata | null = null;
if (value && this._index != null && value[this._index] != null) {
image = value[this._index];
}
const changed = this._value != value || this._index != index || this._image != image;
if (changed) { if (changed) {
if (value && value[this._index] != null) { if (image) {
this._image = value[this._index] this._image = image
this._image.children ||= []
this._image.tags ||= []
this._path = comfyFileToAnnotatedFilepath(this._image.comfyUIFile); this._path = comfyFileToAnnotatedFilepath(this._image.comfyUIFile);
this.filepathWidget.value = this._image.comfyUIFile.filename this.filepathWidget.value = this._image.comfyUIFile.filename
this.folderWidget.value = this._image.comfyUIFile.type this.folderWidget.value = this._image.comfyUIFile.type
this.childrenWidget.value = this._image.children.length
this.tagsWidget.value = this._image.tags.join(", ")
} }
else { else {
this._image = null; this._image = null;
this._path = null; this._path = null;
this.filepathWidget.value = "(None)" this.filepathWidget.value = "(None)"
this.folderWidget.value = "" this.folderWidget.value = ""
this.childrenWidget.value = 0
this.tagsWidget.value = ""
} }
console.log("SET", value, this._image, this._path)
console.log("SET", value, this._image, this._path, this.properties.imageTagFilter)
} }
} }
override onExecute() { override onExecute() {
const data = this.getInputData(0) const data = this.getInputData(0)
const index = this.getInputData(1) || 0 let index = this.getInputData(1);
if (this.properties.imageTagFilter != "" && Array.isArray(data))
index = data.findIndex(i => i.tags?.includes(this.properties.imageTagFilter))
else if (index == null)
index = 0;
this.setValue(data, index); this.setValue(data, index);
if (this._image == null) { if (this._image == null) {
@@ -71,6 +110,7 @@ export default class ComfyPickImageNode extends ComfyGraphNode {
this.setOutputData(1, null) this.setOutputData(1, null)
this.setOutputData(2, 0) this.setOutputData(2, 0)
this.setOutputData(3, 0) this.setOutputData(3, 0)
this.setOutputData(4, null)
this.widthWidget.value = 0 this.widthWidget.value = 0
this.heightWidget.value = 0 this.heightWidget.value = 0
@@ -80,6 +120,7 @@ export default class ComfyPickImageNode extends ComfyGraphNode {
this.setOutputData(1, this._path); this.setOutputData(1, this._path);
this.setOutputData(2, this._image.width); this.setOutputData(2, this._image.width);
this.setOutputData(3, this._image.height); this.setOutputData(3, this._image.height);
this.setOutputData(4, this._image.children);
// XXX: image size doesn't load until the <img> element is ready on // XXX: image size doesn't load until the <img> element is ready on
// the page so this can come after several frames' worth of // the page so this can come after several frames' worth of

View File

@@ -0,0 +1,147 @@
import { nextLetter } from "$lib/utils";
import { LConnectionKind, LLink, LiteGraph, type INodeInputSlot, type INodeOutputSlot, type SlotLayout } from "@litegraph-ts/core";
import ComfyGraphNode from "./ComfyGraphNode";
export default class ComfySwitch extends ComfyGraphNode {
static slotLayout: SlotLayout = {
inputs: [
{ name: "A_value", type: "*" },
{ name: "A_cond", type: "boolean" },
],
outputs: [
{ name: "out", type: "*" }
],
}
override canInheritSlotTypes = true;
private _selected: number | null = null;
constructor(title?: string) {
super(title);
}
override getUpstreamLinkForInheritedType(): LLink | null {
for (let index = 0; index < this.inputs.length / 2; index++) {
const link = this.getInputLink(index * 2);
if (link != null)
return link
}
return null;
}
override getUpstreamLink(): LLink | null {
const selected = this.getSelected();
if (selected == null)
return null;
return this.getInputLink(selected * 2);
}
getSelected(): number | null {
for (let i = 0; i < this.inputs.length / 2; i++) {
if (this.getInputData(i * 2 + 1) == true)
return i
}
return null;
}
override onDrawBackground(ctx: CanvasRenderingContext2D) {
if (this.flags.collapsed || this._selected == null) {
return;
}
ctx.fillStyle = "#AFB";
var y = this._selected * 2 * LiteGraph.NODE_SLOT_HEIGHT + 6;
ctx.beginPath();
ctx.moveTo(30 + 50, y);
ctx.lineTo(30 + 50, y + LiteGraph.NODE_SLOT_HEIGHT);
ctx.lineTo(30 + 34, y + LiteGraph.NODE_SLOT_HEIGHT * 0.5);
ctx.fill();
};
override onExecute() {
this._selected = this.getSelected();
var sel = this._selected
if (sel == null || sel.constructor !== Number) {
this.setOutputData(0, null)
return
}
var v = this.getInputData(sel * 2);
if (v !== undefined) {
this.setOutputData(0, v);
}
}
private hasActiveSlots(pairIndex: number): boolean {
const slotValue = this.inputs[pairIndex * 2]
const slotCond = this.inputs[pairIndex * 2 + 1];
return slotValue && slotCond && (slotValue.link != null || slotCond.link != null);
}
override onConnectionsChange(
type: LConnectionKind,
slotIndex: number,
isConnected: boolean,
link: LLink,
ioSlot: (INodeInputSlot | INodeOutputSlot)
) {
super.onConnectionsChange(type, slotIndex, isConnected, link, ioSlot);
if (type !== LConnectionKind.INPUT)
return;
const lastPairIdx = Math.floor((this.inputs.length / 2) - 1);
let newlyConnected = false;
if (isConnected) {
newlyConnected = this.hasActiveSlots(lastPairIdx)
}
let newlyDisconnected = false;
if (!isConnected) {
newlyDisconnected = !this.hasActiveSlots(lastPairIdx) && !this.hasActiveSlots(lastPairIdx - 1)
}
console.error("CONNCHANGE", lastPairIdx, this.hasActiveSlots(lastPairIdx), isConnected, slotIndex, this.inputs.length, newlyConnected, newlyDisconnected);
if (newlyConnected) {
if (link != null) {
// Add new inputs
const lastInputName = this.inputs[this.inputs.length - 1].name
const inputName = nextLetter(lastInputName.split("_")[0]);
this.addInput(`${inputName}_value`, this.inputs[0].type)
this.addInput(`${inputName}_cond`, "boolean")
}
}
else if (newlyDisconnected) {
// Remove empty inputs
for (let i = this.inputs.length / 2; i > 0; i -= 1) {
if (i <= 0)
break;
if (!this.hasActiveSlots(i - 1)) {
this.removeInput(i * 2)
this.removeInput(i * 2)
}
else {
break;
}
}
let name = "A"
for (let i = 0; i < this.inputs.length; i += 2) {
this.inputs[i].name = `${name}_value`;
this.inputs[i + 1].name = `${name}_cond`
name = nextLetter(name);
}
}
}
}
LiteGraph.registerNodeType({
class: ComfySwitch,
title: "Comfy.Switch",
desc: "Selects an output if its condition is true, if none match returns null",
type: "utils/switch"
})

View File

@@ -47,7 +47,7 @@ export default class ComfyExecuteSubgraphAction extends ComfyGraphNode {
// Hold control to queue at the front // Hold control to queue at the front
const num = app.ctrlDown ? -1 : 0; const num = app.ctrlDown ? -1 : 0;
app.queuePrompt(num, 1, tag); app.queuePrompt(this.workflow, num, 1, tag);
} }
} }

View File

@@ -1,5 +1,6 @@
import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import ComfyGraphNode, { type ComfyGraphNodeProperties } from "../ComfyGraphNode"; import ComfyGraphNode, { type ComfyGraphNodeProperties } from "../ComfyGraphNode";
import { playSound } from "$lib/utils";
export interface ComfyPlaySoundActionProperties extends ComfyGraphNodeProperties { export interface ComfyPlaySoundActionProperties extends ComfyGraphNodeProperties {
sound: string, sound: string,
@@ -21,9 +22,7 @@ export default class ComfyPlaySoundAction extends ComfyGraphNode {
override onAction(action: any, param: any) { override onAction(action: any, param: any) {
const sound = this.getInputData(0) || this.properties.sound; const sound = this.getInputData(0) || this.properties.sound;
if (sound) { if (sound) {
const url = `${location.origin}/sound/${sound}`; playSound(sound)
const audio = new Audio(url);
audio.play();
} }
}; };
} }

View File

@@ -53,7 +53,7 @@ export default class ComfySetNodeModeAction extends ComfyGraphNode {
for (const node of this.graph._nodes) { for (const node of this.graph._nodes) {
if ("tags" in node.properties) { if ("tags" in node.properties) {
const comfyNode = node as ComfyGraphNode; const comfyNode = node as ComfyGraphNode;
const hasTag = tags.some(t => nodeHasTag(comfyNode, t)); const hasTag = tags.some(t => nodeHasTag(comfyNode, t, false));
if (hasTag) { if (hasTag) {
let newMode: NodeMode; let newMode: NodeMode;
if (enabled) { if (enabled) {

View File

@@ -69,7 +69,7 @@ export default class ComfySetNodeModeAdvancedAction extends ComfyGraphNode {
for (const node of this.graph.iterateNodesInOrderRecursive()) { for (const node of this.graph.iterateNodesInOrderRecursive()) {
if ("tags" in node.properties) { if ("tags" in node.properties) {
const comfyNode = node as ComfyGraphNode; const comfyNode = node as ComfyGraphNode;
const hasTag = nodeHasTag(comfyNode, action.tag); const hasTag = nodeHasTag(comfyNode, action.tag, false);
if (hasTag) { if (hasTag) {
let newMode: NodeMode; let newMode: NodeMode;

View File

@@ -2,6 +2,7 @@ export { default as ComfyReroute } from "./ComfyReroute"
export { default as ComfyPickFirstNode } from "./ComfyPickFirstNode" export { default as ComfyPickFirstNode } from "./ComfyPickFirstNode"
export { default as ComfyValueControl } from "./ComfyValueControl" export { default as ComfyValueControl } from "./ComfyValueControl"
export { default as ComfySelector } from "./ComfySelector" export { default as ComfySelector } from "./ComfySelector"
export { default as ComfySwitch } from "./ComfySwitch"
export { default as ComfyTriggerNewEventNode } from "./ComfyTriggerNewEventNode" export { default as ComfyTriggerNewEventNode } from "./ComfyTriggerNewEventNode"
export { default as ComfyConfigureQueuePromptButton } from "./ComfyConfigureQueuePromptButton" export { default as ComfyConfigureQueuePromptButton } from "./ComfyConfigureQueuePromptButton"
export { default as ComfyPickImageNode } from "./ComfyPickImageNode" export { default as ComfyPickImageNode } from "./ComfyPickImageNode"

View File

@@ -41,6 +41,7 @@ export default class ComfyComboNode extends ComfyWidgetNode<string> {
firstLoad: Writable<boolean>; firstLoad: Writable<boolean>;
lightUp: Writable<boolean>; lightUp: Writable<boolean>;
valuesForCombo: Writable<any[]>; // Changed when the combo box has values. valuesForCombo: Writable<any[]>; // Changed when the combo box has values.
maxLabelWidthChars: number = 0;
constructor(name?: string) { constructor(name?: string) {
super(name, "A") super(name, "A")
@@ -77,13 +78,17 @@ export default class ComfyComboNode extends ComfyWidgetNode<string> {
else else
formatter = (value: any) => `${value}`; formatter = (value: any) => `${value}`;
this.maxLabelWidthChars = 0;
let valuesForCombo = [] let valuesForCombo = []
try { try {
valuesForCombo = this.properties.values.map((value, index) => { valuesForCombo = this.properties.values.map((value, index) => {
const label = formatter(value);
this.maxLabelWidthChars = Math.max(this.maxLabelWidthChars, label.length)
return { return {
value, value,
label: formatter(value), label,
index index
} }
}) })
@@ -91,9 +96,11 @@ export default class ComfyComboNode extends ComfyWidgetNode<string> {
catch (err) { catch (err) {
console.error("Failed formatting!", err) console.error("Failed formatting!", err)
valuesForCombo = this.properties.values.map((value, index) => { valuesForCombo = this.properties.values.map((value, index) => {
const label = `${value}`
this.maxLabelWidthChars = Math.max(this.maxLabelWidthChars, label.length)
return { return {
value, value,
label: `${value}`, label,
index index
} }
}) })

View File

@@ -99,6 +99,12 @@ export default class ComfyGalleryNode extends ComfyWidgetNode<ComfyBoxImageMetad
if (newIndex != null) { if (newIndex != null) {
this.selectedImage.set(newIndex) this.selectedImage.set(newIndex)
this.forceSelectImage.set(true) this.forceSelectImage.set(true)
const image = this.getValue()[newIndex]
if (image) {
this.imageWidth.set(image.width || 0)
this.imageHeight.set(image.height || 0)
}
} }
} }

View File

@@ -6,14 +6,17 @@ import ImageUploadWidget from "$lib/widgets/ImageUploadWidget.svelte";
import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; import type { ComfyWidgetProperties } from "./ComfyWidgetNode";
import ComfyWidgetNode from "./ComfyWidgetNode"; import ComfyWidgetNode from "./ComfyWidgetNode";
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import { type LineGroup } from "$lib/components/MaskCanvas.svelte"
export interface ComfyImageUploadNodeProperties extends ComfyWidgetProperties { export interface ComfyImageUploadNodeProperties extends ComfyWidgetProperties {
maskCount: number
} }
export default class ComfyImageUploadNode extends ComfyWidgetNode<ComfyBoxImageMetadata[]> { export default class ComfyImageUploadNode extends ComfyWidgetNode<ComfyBoxImageMetadata[]> {
properties: ComfyImageUploadNodeProperties = { properties: ComfyImageUploadNodeProperties = {
defaultValue: [], defaultValue: [],
tags: [], tags: [],
maskCount: 0
} }
static slotLayout: SlotLayout = { static slotLayout: SlotLayout = {
@@ -39,13 +42,21 @@ export default class ComfyImageUploadNode extends ComfyWidgetNode<ComfyBoxImageM
super(name, []) super(name, [])
} }
override onExecute() { override onExecute(param: any, options: object) {
// TODO better way of getting image size? // TODO better way of getting image size?
const value = get(this.value) const value = get(this.value)
if (value && value.length > 0) { if (value && value.length > 0) {
value[0].width = get(this.imgWidth) value[0].width = get(this.imgWidth)
value[0].height = get(this.imgHeight) value[0].height = get(this.imgHeight)
// NOTE: assumes masks will have the same image size as the parent image!
for (const child of value[0].children) {
child.width = get(this.imgWidth)
child.height = get(this.imgHeight)
}
} }
super.onExecute(param, options);
} }
override parseValue(value: any): ComfyBoxImageMetadata[] { override parseValue(value: any): ComfyBoxImageMetadata[] {

View File

@@ -2,13 +2,16 @@ import { toast } from "@zerodevx/svelte-toast";
import type { SvelteToastOptions } from "@zerodevx/svelte-toast/stores"; import type { SvelteToastOptions } from "@zerodevx/svelte-toast/stores";
import { type Notification } from "framework7/components/notification" import { type Notification } from "framework7/components/notification"
import { f7 } from "framework7-svelte" import { f7 } from "framework7-svelte"
import OnClickToastItem from "$lib/components/OnClickToastItem.svelte"
export type NotifyOptions = { export type NotifyOptions = {
title?: string, title?: string,
type?: "neutral" | "info" | "warning" | "error" | "success", type?: "neutral" | "info" | "warning" | "error" | "success",
imageUrl?: string, imageUrl?: string,
timeout?: number | null, timeout?: number | null,
showOn?: "web" | "native" | "all" | "none" showOn?: "web" | "native" | "all" | "none",
showBar?: boolean,
onClick?: () => void,
} }
function notifyf7(text: string, options: NotifyOptions) { function notifyf7(text: string, options: NotifyOptions) {
@@ -19,13 +22,19 @@ function notifyf7(text: string, options: NotifyOptions) {
if (closeTimeout === undefined) if (closeTimeout === undefined)
closeTimeout = 3000; closeTimeout = 3000;
const on: Notification.Parameters["on"] = {}
if (options.onClick) {
on.click = () => options.onClick();
}
const notification = f7.notification.create({ const notification = f7.notification.create({
title: options.title, title: options.title,
titleRightText: 'now', titleRightText: 'now',
// subtitle: 'Notification with close on click', // subtitle: 'Notification with close on click',
text: text, text: text,
closeOnClick: true, closeOnClick: true,
closeTimeout closeTimeout,
on
}); });
notification.open(); notification.open();
} }
@@ -33,30 +42,47 @@ function notifyf7(text: string, options: NotifyOptions) {
function notifyToast(text: string, options: NotifyOptions) { function notifyToast(text: string, options: NotifyOptions) {
const toastOptions: SvelteToastOptions = { const toastOptions: SvelteToastOptions = {
dismissable: options.timeout !== null, dismissable: options.timeout !== null,
duration: options.timeout || 5000,
theme: {},
}
if (options.showBar) {
toastOptions.theme['--toastBarHeight'] = "6px"
} }
if (options.type === "success") { if (options.type === "success") {
toastOptions.theme = { toastOptions.theme['--toastBackground'] = 'var(--color-green-600)';
'--toastBackground': 'var(--color-green-600)', toastOptions.theme['--toastBarBackground'] = 'var(--color-green-900)';
}
} }
else if (options.type === "info") { else if (options.type === "info") {
toastOptions.theme = { toastOptions.theme['--toastBackground'] = 'var(--color-blue-500)';
'--toastBackground': 'var(--color-blue-500)', toastOptions.theme['--toastBarBackground'] = 'var(--color-blue-800)';
}
} }
else if (options.type === "warning") { else if (options.type === "warning") {
toastOptions.theme = { toastOptions.theme['--toastBackground'] = 'var(--color-yellow-600)';
'--toastBackground': 'var(--color-yellow-600)', toastOptions.theme['--toastBarBackground'] = 'var(--color-yellow-900)';
}
} }
else if (options.type === "error") { else if (options.type === "error") {
toastOptions.theme = { toastOptions.theme['--toastBackground'] = 'var(--color-red-500)';
'--toastBackground': 'var(--color-red-500)', toastOptions.theme['--toastBarBackground'] = 'var(--color-red-800)';
}
} }
toast.push(text, toastOptions); if (options.onClick) {
toast.push({
component: {
src: OnClickToastItem,
props: {
message: text,
notifyOptions: options
},
sendIdTo: "toastID"
},
...toastOptions
})
}
else {
toast.push(text, toastOptions);
}
} }
function notifyNative(text: string, options: NotifyOptions) { function notifyNative(text: string, options: NotifyOptions) {
@@ -78,7 +104,11 @@ function notifyNative(text: string, options: NotifyOptions) {
const notification = new Notification(title, nativeOptions); const notification = new Notification(title, nativeOptions);
notification.onclick = () => window.focus(); notification.onclick = () => {
window.focus();
if (options.onClick)
options.onClick();
}
} }
export default function notify(text: string, options: NotifyOptions = {}) { export default function notify(text: string, options: NotifyOptions = {}) {

View File

@@ -616,6 +616,19 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: true defaultValue: true
}, },
// ImageUpload
{
name: "maskCount",
type: "number",
location: "nodeProps",
editable: true,
validNodeTypes: ["ui/image_upload"],
defaultValue: 0,
min: 0,
max: 8,
step: 1
},
// Radio // Radio
{ {
name: "defaultValue", name: "defaultValue",
@@ -667,6 +680,13 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
location: "workflow", location: "workflow",
editable: true, editable: true,
defaultValue: true defaultValue: true
},
{
name: "showDefaultNotifications",
type: "boolean",
location: "workflow",
editable: true,
defaultValue: true
} }
] ]
} }

View File

@@ -1,10 +1,14 @@
import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, ComfyNodeID, PromptID, QueueItemType } from "$lib/api"; import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, ComfyExecutionError, ComfyNodeID, PromptID, QueueItemType } from "$lib/api";
import type { Progress, SerializedPromptInputsAll, SerializedPromptOutputs, WorkflowInstID } from "$lib/components/ComfyApp"; import type { ComfyAPIPromptErrorResponse } from "$lib/apiErrors";
import type { Progress, SerializedPrompt, SerializedPromptInputsAll, SerializedPromptOutputs, } from "$lib/components/ComfyApp";
import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes"; import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify"; import notify from "$lib/notify";
import { playSound } from "$lib/utils";
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import { v4 as uuidv4 } from "uuid";
import workflowState, { type WorkflowError, type WorkflowExecutionError, type WorkflowInstID, type WorkflowValidationError } from "./workflowState";
export type QueueEntryStatus = "success" | "error" | "interrupted" | "all_cached" | "unknown"; export type QueueEntryStatus = "success" | "validation_failed" | "error" | "interrupted" | "all_cached" | "unknown";
type QueueStateOps = { type QueueStateOps = {
queueUpdated: (resp: ComfyAPIQueueResponse) => void, queueUpdated: (resp: ComfyAPIQueueResponse) => void,
@@ -13,13 +17,14 @@ type QueueStateOps = {
executionStart: (promptID: PromptID) => void, executionStart: (promptID: PromptID) => void,
executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => QueueEntry | null; executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => QueueEntry | null;
executionCached: (promptID: PromptID, nodes: ComfyNodeID[]) => void, executionCached: (promptID: PromptID, nodes: ComfyNodeID[]) => void,
executionError: (promptID: PromptID, message: string) => void, executionError: (error: ComfyExecutionError) => CompletedQueueEntry | null,
progressUpdated: (progress: Progress) => void progressUpdated: (progress: Progress) => void
getQueueEntry: (promptID: PromptID) => QueueEntry | null; getQueueEntry: (promptID: PromptID) => QueueEntry | null;
afterQueued: (workflowID: WorkflowInstID, promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void afterQueued: (workflowID: WorkflowInstID, promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void
queueItemDeleted: (type: QueueItemType, id: PromptID) => void; queueItemDeleted: (type: QueueItemType, id: PromptID) => void;
queueCleared: (type: QueueItemType) => void; queueCleared: (type: QueueItemType) => void;
onExecuted: (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => QueueEntry | null onExecuted: (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => QueueEntry | null
promptError: (id: WorkflowInstID, error: ComfyAPIPromptErrorResponse, prompt: SerializedPrompt, extraData: ComfyBoxPromptExtraData) => PromptID
} }
/* /*
@@ -32,6 +37,10 @@ export type QueueEntry = {
number: number, number: number,
queuedAt?: Date, queuedAt?: Date,
finishedAt?: Date, finishedAt?: Date,
/*
* Can also be generated by the frontend if prompt validation fails
* (the backend won't send back a prompt ID in that case)
*/
promptID: PromptID, promptID: PromptID,
prompt: SerializedPromptInputsAll, prompt: SerializedPromptInputsAll,
extraData: ComfyBoxPromptExtraData, extraData: ComfyBoxPromptExtraData,
@@ -44,7 +53,7 @@ export type QueueEntry = {
/* Nodes of the workflow that have finished running so far. */ /* Nodes of the workflow that have finished running so far. */
nodesRan: Set<ComfyNodeID>, nodesRan: Set<ComfyNodeID>,
/* Nodes of the workflow the backend reported as cached. */ /* Nodes of the workflow the backend reported as cached. */
cachedNodes: Set<ComfyNodeID> cachedNodes: Set<ComfyNodeID>,
} }
/* /*
@@ -59,7 +68,7 @@ export type CompletedQueueEntry = {
/** Message to display in the frontend */ /** Message to display in the frontend */
message?: string, message?: string,
/** Detailed error/stacktrace, perhaps inspectible with a popup */ /** Detailed error/stacktrace, perhaps inspectible with a popup */
error?: string, error?: WorkflowError
} }
/* /*
@@ -227,21 +236,32 @@ function moveToRunning(index: number, queue: Writable<QueueEntry[]>) {
store.set(state) store.set(state)
} }
function moveToCompleted(index: number, queue: Writable<QueueEntry[]>, status: QueueEntryStatus, message?: string, error?: string) { function moveToCompleted(index: number, queue: Writable<QueueEntry[]>, status: QueueEntryStatus, message?: string, error?: ComfyExecutionError): CompletedQueueEntry {
const state = get(store) const state = get(store)
const entry = get(queue)[index]; const entry = get(queue)[index];
let workflowError: WorkflowExecutionError | null = null;
if (error) {
workflowError = {
type: "execution",
error
}
entry.nodesRan = new Set(error.executed);
}
console.debug("[queueState] Move to completed", entry.promptID, index, status, message, error) console.debug("[queueState] Move to completed", entry.promptID, index, status, message, error)
entry.finishedAt = new Date() // Now entry.finishedAt = new Date() // Now
queue.update(qp => { qp.splice(index, 1); return qp }); queue.update(qp => { qp.splice(index, 1); return qp });
const completed: CompletedQueueEntry = { entry, status, message, error: workflowError }
state.queueCompleted.update(qc => { state.queueCompleted.update(qc => {
const completed: CompletedQueueEntry = { entry, status, message, error }
qc.push(completed) qc.push(completed)
return qc return qc
}) })
state.isInterrupting = false; state.isInterrupting = false;
store.set(state) store.set(state)
return completed;
} }
function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null): QueueEntry | null { function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null): QueueEntry | null {
@@ -267,6 +287,11 @@ function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null)
moveToCompleted(index, queue, "all_cached", "(Execution was cached)"); moveToCompleted(index, queue, "all_cached", "(Execution was cached)");
} }
else if (entry.nodesRan.size >= totalNodesInPrompt) { else if (entry.nodesRan.size >= totalNodesInPrompt) {
const workflow = workflowState.getWorkflow(entry.extraData.workflowID);
if (workflow?.attrs.showDefaultNotifications) {
notify("Prompt finished!", { type: "success" });
playSound("notification.mp3")
}
moveToCompleted(index, queue, "success") moveToCompleted(index, queue, "success")
} }
else { else {
@@ -307,20 +332,22 @@ function executionCached(promptID: PromptID, nodes: ComfyNodeID[]) {
}) })
} }
function executionError(promptID: PromptID, message: string) { function executionError(error: ComfyExecutionError): CompletedQueueEntry | null {
console.debug("[queueState] executionError", promptID, message) console.debug("[queueState] executionError", error)
let entry_ = null;
store.update(s => { store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID); const [index, entry, queue] = findEntryInPending(error.prompt_id);
if (entry != null) { if (entry != null) {
moveToCompleted(index, queue, "error", "Error executing", message) entry_ = moveToCompleted(index, queue, "error", "Error executing", error)
} }
else { else {
console.error("[queueState] Could not find in pending! (executionError)", promptID) console.error("[queueState] Could not find in pending! (executionError)", error.prompt_id)
} }
s.progress = null; s.progress = null;
s.runningNodeID = null; s.runningNodeID = null;
return s return s
}) })
return entry_;
} }
function createNewQueueEntry(promptID: PromptID, number: number = -1, prompt: SerializedPromptInputsAll = {}, extraData: any = {}): QueueEntry { function createNewQueueEntry(promptID: PromptID, number: number = -1, prompt: SerializedPromptInputsAll = {}, extraData: any = {}): QueueEntry {
@@ -425,6 +452,43 @@ function queueCleared(type: QueueItemType) {
}) })
} }
function promptError(workflowID: WorkflowInstID, error: ComfyAPIPromptErrorResponse, prompt: SerializedPrompt, extraData: ComfyBoxPromptExtraData): PromptID {
const workflowError: WorkflowValidationError = {
type: "validation",
workflowID,
error,
prompt,
extraData
}
const entry: QueueEntry = {
number: 0,
queuedAt: new Date(), // Now
finishedAt: new Date(),
promptID: uuidv4(), // Just for keeping track
prompt: prompt.output,
extraData,
goodOutputs: [],
outputs: {},
nodesRan: new Set(),
cachedNodes: new Set(),
}
const completedEntry: CompletedQueueEntry = {
entry,
status: "validation_failed",
message: "Validation failed",
error: workflowError
}
store.update(s => {
s.queueCompleted.update(qc => { qc.push(completedEntry); return qc })
return s;
})
return entry.promptID;
}
const queueStateStore: WritableQueueStateStore = const queueStateStore: WritableQueueStateStore =
{ {
...store, ...store,
@@ -440,6 +504,7 @@ const queueStateStore: WritableQueueStateStore =
queueItemDeleted, queueItemDeleted,
queueCleared, queueCleared,
getQueueEntry, getQueueEntry,
onExecuted onExecuted,
promptError,
} }
export default queueStateStore; export default queueStateStore;

View File

@@ -1,3 +1,4 @@
import type { PromptID } from '$lib/api';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store'; import type { Readable, Writable } from 'svelte/store';
@@ -11,7 +12,9 @@ export type UIState = {
uiEditMode: UIEditMode, uiEditMode: UIEditMode,
reconnecting: boolean, reconnecting: boolean,
forceSaveUserState: boolean | null forceSaveUserState: boolean | null,
activeError: PromptID | null
} }
type UIStateOps = { type UIStateOps = {
@@ -30,6 +33,8 @@ const store: Writable<UIState> = writable(
reconnecting: false, reconnecting: false,
forceSaveUserState: null, forceSaveUserState: null,
activeError: null
}) })
function reconnecting() { function reconnecting() {

View File

@@ -8,8 +8,10 @@ import layoutStates from './layoutStates';
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type ComfyGraphCanvas from '$lib/ComfyGraphCanvas'; import type ComfyGraphCanvas from '$lib/ComfyGraphCanvas';
import { blankGraph } from '$lib/defaultGraph'; import { blankGraph } from '$lib/defaultGraph';
import type { SerializedAppState } from '$lib/components/ComfyApp'; import type { SerializedAppState, SerializedPrompt } from '$lib/components/ComfyApp';
import type ComfyReceiveOutputNode from '$lib/nodes/actions/ComfyReceiveOutputNode'; import type ComfyReceiveOutputNode from '$lib/nodes/actions/ComfyReceiveOutputNode';
import type { ComfyBoxPromptExtraData, PromptID } from '$lib/api';
import type { ComfyAPIPromptErrorResponse, ComfyExecutionError } from '$lib/apiErrors';
type ActiveCanvas = { type ActiveCanvas = {
canvas: LGraphCanvas | null; canvas: LGraphCanvas | null;
@@ -54,8 +56,30 @@ export type WorkflowAttributes = {
* Comfy.QueueEvents node. * Comfy.QueueEvents node.
*/ */
queuePromptButtonRunWorkflow: boolean, queuePromptButtonRunWorkflow: boolean,
/*
* If true, notifications will be shown when a prompt is queued and
* completed. Set to false if you need more detailed control over the
* notification type/contents, and use the `ComfyNotifyAction` node instead.
*/
showDefaultNotifications: boolean,
} }
export type WorkflowValidationError = {
type: "validation"
workflowID: WorkflowInstID,
error: ComfyAPIPromptErrorResponse,
prompt: SerializedPrompt,
extraData: ComfyBoxPromptExtraData
}
export type WorkflowExecutionError = {
type: "execution"
error: ComfyExecutionError,
}
export type WorkflowError = WorkflowValidationError | WorkflowExecutionError;
export class ComfyBoxWorkflow { export class ComfyBoxWorkflow {
/* /*
* Used for uniquely identifying the instance of the opened workflow in the frontend. * Used for uniquely identifying the instance of the opened workflow in the frontend.
@@ -82,6 +106,11 @@ export class ComfyBoxWorkflow {
*/ */
missingNodeTypes: Set<string> = new Set(); missingNodeTypes: Set<string> = new Set();
/*
* Completed queue entry ID that holds the last validation/execution error.
*/
lastError?: PromptID
get layout(): WritableLayoutStateStore | null { get layout(): WritableLayoutStateStore | null {
return layoutStates.getLayout(this.id) return layoutStates.getLayout(this.id)
} }
@@ -217,7 +246,7 @@ export class ComfyBoxWorkflow {
// this.#invokeExtensions("loadedGraphNode", node); // this.#invokeExtensions("loadedGraphNode", node);
} }
this.attrs = data.attrs; this.attrs = { ...defaultWorkflowAttributes, ...data.attrs };
// Now restore the layout // Now restore the layout
// Subsequent added nodes will add the UI data to layoutState // Subsequent added nodes will add the UI data to layoutState
@@ -250,7 +279,10 @@ type WorkflowStateOps = {
closeWorkflow: (canvas: ComfyGraphCanvas, index: number) => void, closeWorkflow: (canvas: ComfyGraphCanvas, index: number) => void,
closeAllWorkflows: (canvas: ComfyGraphCanvas) => void, closeAllWorkflows: (canvas: ComfyGraphCanvas) => void,
setActiveWorkflow: (canvas: ComfyGraphCanvas, index: number | WorkflowInstID) => ComfyBoxWorkflow | null, setActiveWorkflow: (canvas: ComfyGraphCanvas, index: number | WorkflowInstID) => ComfyBoxWorkflow | null,
findReceiveOutputTargets: (type: SlotType | SlotType[]) => WorkflowReceiveOutputTargets[] findReceiveOutputTargets: (type: SlotType | SlotType[]) => WorkflowReceiveOutputTargets[],
afterQueued: (id: WorkflowInstID, promptID: PromptID) => void
promptError: (id: WorkflowInstID, promptID: PromptID) => void
executionError: (id: WorkflowInstID, promptID: PromptID) => void
} }
export type WritableWorkflowStateStore = Writable<WorkflowState> & WorkflowStateOps; export type WritableWorkflowStateStore = Writable<WorkflowState> & WorkflowStateOps;
@@ -413,6 +445,36 @@ function findReceiveOutputTargets(type: SlotType | SlotType[]): WorkflowReceiveO
return result; return result;
} }
function afterQueued(id: WorkflowInstID, promptID: PromptID) {
const workflow = getWorkflow(id);
if (workflow == null) {
console.warn("[workflowState] afterQueued: workflow not found", id, promptID)
return
}
workflow.lastError = null;
}
function promptError(id: WorkflowInstID, promptID: PromptID) {
const workflow = getWorkflow(id);
if (workflow == null) {
console.warn("[workflowState] promptError: workflow not found", id, promptID)
return
}
workflow.lastError = promptID;
}
function executionError(id: WorkflowInstID, promptID: PromptID) {
const workflow = getWorkflow(id);
if (workflow == null) {
console.warn("[workflowState] executionError: workflow not found", id, promptID)
return
}
workflow.lastError = promptID;
}
const workflowStateStore: WritableWorkflowStateStore = const workflowStateStore: WritableWorkflowStateStore =
{ {
...store, ...store,
@@ -427,6 +489,9 @@ const workflowStateStore: WritableWorkflowStateStore =
closeWorkflow, closeWorkflow,
closeAllWorkflows, closeAllWorkflows,
setActiveWorkflow, setActiveWorkflow,
findReceiveOutputTargets findReceiveOutputTargets,
afterQueued,
promptError,
executionError,
} }
export default workflowStateStore; export default workflowStateStore;

View File

@@ -75,6 +75,13 @@ export function download(filename: string, text: string, type: string = "text/pl
}, 0); }, 0);
} }
export function downloadCanvas(canvas: HTMLCanvasElement, filename: string, type: string = "image/png") {
var link = document.createElement('a');
link.download = filename;
link.href = canvas.toDataURL(type);
link.click();
}
export const MAX_LOCAL_STORAGE_MB = 5; export const MAX_LOCAL_STORAGE_MB = 5;
export function getLocalStorageUsedMB(): number { export function getLocalStorageUsedMB(): number {
@@ -434,6 +441,8 @@ export type ComfyBoxImageMetadata = {
width?: number, width?: number,
/* Image height. */ /* Image height. */
height?: number, height?: number,
/* Child images associated with this image, like masks. */
children: ComfyBoxImageMetadata[]
} }
export function isComfyBoxImageMetadata(value: any): value is ComfyBoxImageMetadata { export function isComfyBoxImageMetadata(value: any): value is ComfyBoxImageMetadata {
@@ -458,6 +467,7 @@ export function filenameToComfyBoxMetadata(filename: string, type: ComfyUploadIm
}, },
name: "Filename", name: "Filename",
tags: [], tags: [],
children: []
} }
} }
@@ -467,6 +477,7 @@ export function comfyFileToComfyBoxMetadata(comfyUIFile: ComfyImageLocation): Co
comfyUIFile, comfyUIFile,
name: "File", name: "File",
tags: [], tags: [],
children: []
} }
} }
@@ -610,3 +621,88 @@ export async function readFileToText(file: File): Promise<string> {
reader.readAsText(file); reader.readAsText(file);
}) })
} }
export function nextLetter(s: string): string {
return s.replace(/([a-zA-Z])[^a-zA-Z]*$/, function(a) {
var c = a.charCodeAt(0);
switch (c) {
case 90: return 'A';
case 122: return 'a';
default: return String.fromCharCode(++c);
}
});
}
export function playSound(sound: string) {
const url = `${location.origin}/sound/${sound}`;
const audio = new Audio(url);
audio.play();
}
export interface ComfyBatchUploadResult {
error?: string;
files: Array<ComfyImageLocation>;
}
export type ComfyBatchBlob = {
blob: Blob,
filename: string,
overwrite?: boolean
}
export async function batchUploadFilesToComfyUI(files: Array<File>): Promise<ComfyBatchUploadResult> {
const blobs = files.map(f => { return { blob: f, filename: f.name } })
return batchUploadBlobsToComfyUI(blobs)
}
export async function batchUploadBlobsToComfyUI(blobs: ComfyBatchBlob[]): Promise<ComfyBatchUploadResult> {
const url = configState.getBackendURL();
const requests = blobs.map(async (blob) => {
const formData = new FormData();
formData.append("image", blob.blob, blob.filename);
if (blob.overwrite) {
formData.append("overwrite", "true")
}
return fetch(new Request(url + "/upload/image", {
body: formData,
method: 'POST'
}))
.then(r => r.json())
.catch(error => error);
});
return Promise.all(requests)
.then((results) => {
const errors = []
const files = []
for (const r of results) {
if (r instanceof Error) {
errors.push(r.toString())
}
else {
// bare filename of image
const resp = r as ComfyUploadImageAPIResponse;
files.push({
filename: resp.name,
subfolder: "",
type: "input"
})
}
}
let error = null;
if (errors && errors.length > 0)
error = "Upload error(s):\n" + errors.join("\n");
return { error, files }
})
}
export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise(function(resolve) {
canvas.toBlob(resolve);
});
}

View File

@@ -129,10 +129,25 @@
}; };
} }
let title: ""
$: nodeValue && $nodeValue && (title = getTitle($nodeValue))
function getTitle(value?: string) {
if (value == null) {
if (!nodeValue)
return ""
value = $nodeValue
}
if (value && value.length > 80)
return String(value)
return ""
}
</script> </script>
<div class="wrapper comfy-combo" class:mobile={isMobile} class:updated={$lightUp}> <div class="wrapper comfy-combo" class:mobile={isMobile} class:updated={$lightUp}>
<label> <label title={title}>
{#if widget.attrs.title !== ""} {#if widget.attrs.title !== ""}
<BlockTitle show_label={true}> <BlockTitle show_label={true}>
{widget.attrs.title} {widget.attrs.title}
@@ -158,7 +173,7 @@
on:select={(e) => handleSelect(e.detail.index)} on:select={(e) => handleSelect(e.detail.index)}
on:blur on:blur
on:filter={onFilter}> on:filter={onFilter}>
<div class="comfy-select-list" slot="list" let:filteredItems> <div class="comfy-select-list" slot="list" let:filteredItems style:--maxLabelWidth={node.maxLabelWidthChars || 100}>
{#if filteredItems.length > 0} {#if filteredItems.length > 0}
{@const itemSize = isMobile ? 50 : 25} {@const itemSize = isMobile ? 50 : 25}
{@const itemsToShow = isMobile ? 10 : 30} {@const itemsToShow = isMobile ? 10 : 30}
@@ -175,6 +190,7 @@
class:mobile={isMobile} class:mobile={isMobile}
let:index={i} let:index={i}
let:style let:style
title={getTitle(filteredItems[i].label)}
{style} {style}
class:active={activeIndex === filteredItems[i].index} class:active={activeIndex === filteredItems[i].index}
class:hover={hoverItemIndex === i} class:hover={hoverItemIndex === i}
@@ -274,7 +290,9 @@
} }
.comfy-select-list { .comfy-select-list {
width: 30rem; --maxLabelWidth: 100;
font-size: 14px;
width: min(calc((var(--maxLabelWidth) + 10) * 1ch), 50vw);
color: var(--item-color); color: var(--item-color);
> :global(.virtual-list-wrapper) { > :global(.virtual-list-wrapper) {

View File

@@ -3,46 +3,140 @@
import { Block } from "@gradio/atoms"; import { Block } from "@gradio/atoms";
import { TextBox } from "@gradio/form"; import { TextBox } from "@gradio/form";
import Row from "$lib/components/gradio/app/Row.svelte"; import Row from "$lib/components/gradio/app/Row.svelte";
import { get, writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import Modal from "$lib/components/Modal.svelte";
import { Button } from "@gradio/button"; import { Button } from "@gradio/button";
import { Embed as Klecks } from "klecks"; import {
type ComfyBoxImageMetadata,
import "klecks/style/style.scss"; comfyFileToComfyBoxMetadata,
comfyBoxImageToComfyFile,
type ComfyImageLocation,
comfyBoxImageToComfyURL,
convertComfyOutputToComfyURL,
batchUploadBlobsToComfyUI,
canvasToBlob,
basename
} from "$lib/utils";
import ImageUpload from "$lib/components/ImageUpload.svelte"; 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 notify from "$lib/notify";
import NumberInput from "$lib/components/NumberInput.svelte"; import { ImageViewer } from "$lib/ImageViewer";
import type { ComfyImageEditorNode } from "$lib/nodes/widgets"; import MaskCanvas, { type LineGroup, type MaskCanvasData } from "$lib/components/MaskCanvas.svelte";
import { ImageViewer } from "$lib/ImageViewer"; import type { ComfyImageUploadNode } from "$lib/nodes/widgets";
import { generateBlankCanvas, generateImageCanvas } from "./utils"; import { tick } from "svelte";
export let widget: WidgetLayout | null = null; export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false; export let isMobile: boolean = false;
let node: ComfyImageEditorNode | null = null; let node: ComfyImageUploadNode | null = null;
let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null; let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null;
let attrsChanged: Writable<number> | null = null;
let imgWidth: Writable<number> = writable(0); let imgWidth: Writable<number> = writable(0);
let imgHeight: Writable<number> = writable(0); let imgHeight: Writable<number> = writable(0);
let maskCanvasComp: MaskCanvas | null = null;
let editMask: boolean = false;
$: widget && setNodeValue(widget); $: widget && setNodeValue(widget);
let canMask = false;
$: canMask = (node?.properties?.maskCount || 0) > 0;
$: if (!canMask) clearMask();
function setNodeValue(widget: WidgetLayout) { function setNodeValue(widget: WidgetLayout) {
if (widget) { if (widget) {
node = widget.node as ComfyImageEditorNode node = widget.node as ComfyImageUploadNode
nodeValue = node.value; nodeValue = node.value;
attrsChanged = widget.attrsChanged;
imgWidth = node.imgWidth imgWidth = node.imgWidth
imgHeight = node.imgHeight imgHeight = node.imgHeight
status = $nodeValue && $nodeValue.length > 0 ? "uploaded" : "empty" status = $nodeValue && $nodeValue.length > 0 ? "uploaded" : "empty"
} }
}; };
let hasImage = false;
$: hasImage = $nodeValue && $nodeValue.length > 0;
$: if (!hasImage) {
editMask = false;
}
const MASK_FILENAME: string = "ComfyBoxMask.png"
async function onMaskReleased(e: CustomEvent<MaskCanvasData>) {
const data = e.detail;
if (data.maskCanvas != null && data.hasMask) {
await saveMask(data.maskCanvas)
}
}
async function saveMask(maskCanvas: HTMLCanvasElement) {
if (!canMask) {
notify("Mask editing is disabled for this widget.", { type: "warning" })
return;
}
if (!maskCanvas) {
notify("No mask canvas!", { type: "warning" })
return
}
if (!$nodeValue || $nodeValue.length === 0) {
notify("No image uploaded to apply mask to.", { type: "warning" })
return
}
const hadNoMask = $nodeValue[0].children.findIndex(i => i.tags?.includes("mask")) === -1;
const existFilename = $nodeValue[0].comfyUIFile.filename
const filename = existFilename ? `${basename(existFilename)}_mask.png` : MASK_FILENAME
console.warn("[ImageUpload] UPLOAD MASK", filename)
await canvasToBlob(maskCanvas)
.then(blob => batchUploadBlobsToComfyUI([{
blob,
filename,
overwrite: true
}]))
.then(result => {
const meta = result.files.map(f => {
const m = comfyFileToComfyBoxMetadata(f)
m.tags = ["mask"]
m.width = maskCanvas.width;
m.height = maskCanvas.height;
return m;
});
if ($nodeValue.length > 0) {
// TODO support multiple images?
$nodeValue[0].children = meta;
if (hadNoMask) {
notify("Uploaded mask successfully!", { type: "success" })
}
}
else {
throw new Error("No image was uploaded yet.")
}
})
.catch(error => {
notify(`Failed to upload mask to ComfyUI: ${error}`, { type: "error", timeout: 10000 })
})
}
function clearMask() {
for (const image of $nodeValue) {
// TODO other child image types preserved here?
image.children = [];
}
if (maskCanvasComp) {
maskCanvasComp.clearStrokes();
}
}
async function toggleEditMask() {
editMask = !editMask;
await tick();
if (maskCanvasComp) {
maskCanvasComp.recenterImage();
}
}
let editorRoot: HTMLDivElement | null = null; let editorRoot: HTMLDivElement | null = null;
let showModal = false; let showModal = false;
let kl: Klecks | null = null;
function disposeEditor() { function disposeEditor() {
console.warn("[ImageEditorWidget] CLOSING", widget, $nodeValue) console.warn("[ImageEditorWidget] CLOSING", widget, $nodeValue)
@@ -53,96 +147,9 @@
} }
} }
kl = null;
showModal = false; showModal = false;
} }
const FILENAME: string = "ComfyUITemp.png";
const SUBFOLDER: string = "ComfyBox_Editor";
const DIRECTORY: ComfyUploadImageType = "input";
async function submitKlecksToComfyUI(onSuccess: () => void, onError: () => void) {
const blob = kl.getPNG();
status = "uploading"
await uploadImageToComfyUI(blob, FILENAME, DIRECTORY, SUBFOLDER)
.then((entry: ComfyImageLocation) => {
const meta: ComfyBoxImageMetadata = comfyFileToComfyBoxMetadata(entry);
$nodeValue = [meta] // TODO more than one image
status = "uploaded"
notify("Saved image to ComfyUI!", { type: "success" })
onSuccess();
})
.catch(err => {
notify(`Failed to upload image from editor: ${err}`, { type: "error", timeout: 10000 })
status = "error"
uploadError = err;
$nodeValue = []
onError();
})
}
let closeDialog = null;
async function saveAndClose() {
console.log(closeDialog, kl)
if (!closeDialog || !kl)
return;
submitKlecksToComfyUI(() => {}, () => {});
closeDialog()
}
let blankImageWidth = 512;
let blankImageHeight = 512;
async function openImageEditor() {
if (!editorRoot)
return;
showModal = true;
const url = configState.getBackendURL();
kl = new Klecks({
embedUrl: url,
onSubmit: submitKlecksToComfyUI,
targetEl: editorRoot,
warnOnPageClose: false
});
console.warn("[ImageEditorWidget] OPENING", widget, $nodeValue)
let canvas = null;
let width = blankImageWidth;
let height = blankImageHeight;
if ($nodeValue && $nodeValue.length > 0) {
const comfyImage = $nodeValue[0];
const comfyURL = comfyBoxImageToComfyURL(comfyImage);
[canvas, width, height] = await generateImageCanvas(comfyURL);
}
else {
canvas = generateBlankCanvas(width, height);
}
kl.openProject({
width: width,
height: height,
layers: [{
name: 'Image',
opacity: 1,
mixModeStr: 'source-over',
image: canvas
}]
});
setTimeout(function () {
kl?.klApp?.out("yo");
}, 1000);
}
function openLightbox() { function openLightbox() {
if (!$nodeValue || $nodeValue.length === 0) if (!$nodeValue || $nodeValue.length === 0)
return; return;
@@ -185,9 +192,6 @@
notify(`Failed to upload image to ComfyUI: ${uploadError}`, { type: "error", timeout: 10000 }) notify(`Failed to upload image to ComfyUI: ${uploadError}`, { type: "error", timeout: 10000 })
} }
function onChange(e: CustomEvent<ComfyImageLocation[]>) {
}
let _value: ComfyImageLocation[] = [] let _value: ComfyImageLocation[] = []
$: if ($nodeValue) $: if ($nodeValue)
_value = $nodeValue.map(comfyBoxImageToComfyFile) _value = $nodeValue.map(comfyBoxImageToComfyFile)
@@ -195,6 +199,9 @@
_value = [] _value = []
$: canEdit = status === "empty" || status === "uploaded"; $: canEdit = status === "empty" || status === "uploaded";
function onChange(e: CustomEvent<ComfyImageLocation[]>) {
}
</script> </script>
<div class="wrapper comfy-image-editor"> <div class="wrapper comfy-image-editor">
@@ -215,64 +222,50 @@
/> />
{:else} {:else}
<div class="comfy-image-editor-panel"> <div class="comfy-image-editor-panel">
<ImageUpload value={_value} {#if _value && canMask}
bind:imgWidth={$imgWidth} {@const comfyURL = convertComfyOutputToComfyURL(_value[0])}
bind:imgHeight={$imgHeight} <div class="mask-canvas-wrapper" style:display={editMask ? "block" : "none"}>
fileCount={"single"} <MaskCanvas bind:this={maskCanvasComp} fileURL={comfyURL} on:release={onMaskReleased} on:loaded={onMaskReleased} />
elem_classes={[]}
style={""}
label={widget.attrs.title}
on:uploading={onUploading}
on:uploaded={onUploaded}
on:upload_error={onUploadError}
on:clear={onClear}
on:change={onChange}
on:image_clicked={openLightbox}
/>
<Modal bind:showModal closeOnClick={false} on:close={disposeEditor} bind:closeDialog>
<div>
<div id="klecks-loading-screen">
<span id="klecks-loading-screen-text"></span>
</div>
<div class="image-editor-root" bind:this={editorRoot} />
</div> </div>
<div slot="buttons"> {/if}
<Button variant="primary" on:click={saveAndClose}> <div style:display={(canMask && editMask) ? "none" : "block"}>
Save and Close <ImageUpload value={_value}
</Button> bind:imgWidth={$imgWidth}
<Button variant="secondary" on:click={closeDialog}> bind:imgHeight={$imgHeight}
Discard Edits fileCount={"single"}
</Button> elem_classes={[]}
</div> style={""}
</Modal> label={widget.attrs.title}
on:uploading={onUploading}
on:uploaded={onUploaded}
on:upload_error={onUploadError}
on:clear={onClear}
on:change={onChange}
on:image_clicked={openLightbox}
/>
</div>
<Block> <Block>
{#if !$nodeValue || $nodeValue.length === 0} {#if hasImage}
{@const maskCount = $nodeValue[0] ? $nodeValue[0].children.filter(f => f.tags?.includes("mask")).length : 0}
<Row> <Row>
<Row> {#if canMask}
<Button variant="secondary" disabled={!canEdit} on:click={openImageEditor}>
Create Image
</Button>
<div> <div>
<TextBox show_label={false} disabled={true} value="Status: {status}"/> {#if editMask}
<Button variant="secondary" on:click={() => { clearMask(); notify("Mask cleared."); }}>
Clear Mask
</Button>
{/if}
<Button disabled={!_value} on:click={toggleEditMask}>
{#if editMask}
Show Image
{:else}
Edit Mask
{/if}
</Button>
</div> </div>
{#if uploadError} {/if}
<div>
Upload error: {uploadError}
</div>
{/if}
</Row>
<Row>
<NumberInput label={"Width"} min={64} max={2048} step={64} bind:value={blankImageWidth} />
<NumberInput label={"Height"} min={64} max={2048} step={64} bind:value={blankImageHeight} />
</Row>
</Row>
{:else}
<Row>
<Button variant="secondary" disabled={!canEdit} on:click={openImageEditor}>
Edit Image
</Button>
<div> <div>
<TextBox label={""} show_label={false} disabled={true} lines={1} max_lines={1} value="Status: {status}"/> <TextBox label={""} show_label={false} disabled={true} lines={1} max_lines={1} value="Images: {$nodeValue.length}, masks: {maskCount}"/>
</div> </div>
{#if uploadError} {#if uploadError}
<div> <div>
@@ -287,25 +280,13 @@
</div> </div>
<style lang="scss"> <style lang="scss">
.image-editor-root {
width: 75vw;
height: 75vh;
overflow: hidden;
color: black;
:global(> .g-root) {
height: calc(100% - 59px);
}
}
.comfy-image-editor { .comfy-image-editor {
:global(> dialog) { :global(> dialog) {
overflow: hidden; overflow: hidden;
} }
} }
:global(.kl-popup) { .mask-canvas-wrapper {
z-index: 999999999999; height: calc(var(--size-96) * 1.5);
} }
</style> </style>