27 Commits

Author SHA1 Message Date
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
35 changed files with 9479 additions and 7519 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
}, },

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",

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)
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 }) 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,7 +992,7 @@ 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) {
@@ -1008,8 +1032,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 +1044,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 +1295,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,10 +79,15 @@
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;
return canvas.closest(".splitpanes__pane")
}
$: if (containerElem) {
const paneNode = getGraphPane();
if (paneNode) { if (paneNode) {
(paneNode as HTMLElement).ontransitionstart = () => { (paneNode as HTMLElement).ontransitionstart = () => {
$interfaceState.graphTransitioning = true $interfaceState.graphTransitioning = true
@@ -84,7 +98,6 @@
} }
} }
} }
}
function queuePrompt() { function queuePrompt() {
app.runDefaultQueueAction() app.runDefaultQueueAction()
@@ -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 on:click={doRecenter}>Recenter</button> <button disabled={$interfaceState.graphTransitioning} on:click={doRecenter}>Recenter</button>
</span> {#if $uiState.activeError != null}
<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) if (subgraphs) {
message += ` (${subgraphs.join(', ')})` const subgraphsString = subgraphs.join(', ')
if (subgraphsString.length > 0)
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,11 +236,21 @@
let selectedPrompt = null; let selectedPrompt = null;
let selectedImages = []; let selectedImages = [];
function showPrompt(entry: QueueUIEntry) { function showPrompt(entry: QueueUIEntry) {
if (entry.error != null) {
showModal = false;
expandAll = false;
selectedPrompt = null;
selectedImages = [];
showError(entry.entry.promptID);
}
else {
selectedPrompt = entry.entry.prompt; selectedPrompt = entry.entry.prompt;
selectedImages = entry.images; selectedImages = entry.images;
showModal = true; showModal = true;
expandAll = false expandAll = false
} }
}
function closeModal() { function closeModal() {
selectedPrompt = null selectedPrompt = null

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

@@ -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

@@ -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

@@ -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

@@ -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,31 +42,48 @@ 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)';
}
} }
if (options.onClick) {
toast.push({
component: {
src: OnClickToastItem,
props: {
message: text,
notifyOptions: options
},
sendIdTo: "toastID"
},
...toastOptions
})
}
else {
toast.push(text, toastOptions); toast.push(text, toastOptions);
} }
}
function notifyNative(text: string, options: NotifyOptions) { function notifyNative(text: string, options: NotifyOptions) {
if (document.hasFocus()) if (document.hasFocus())
@@ -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

@@ -667,6 +667,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

@@ -610,3 +610,21 @@ 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();
}

View File

@@ -6,7 +6,7 @@
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import Modal from "$lib/components/Modal.svelte"; 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 Embed as Klecks } from "klecks";
import "klecks/style/style.scss"; import "klecks/style/style.scss";
import ImageUpload from "$lib/components/ImageUpload.svelte"; import ImageUpload from "$lib/components/ImageUpload.svelte";
@@ -97,6 +97,8 @@
let blankImageWidth = 512; let blankImageWidth = 512;
let blankImageHeight = 512; let blankImageHeight = 512;
let klecks: typeof import("klecks") | null = null;
async function openImageEditor() { async function openImageEditor() {
if (!editorRoot) if (!editorRoot)
return; return;
@@ -105,7 +107,9 @@
const url = configState.getBackendURL(); const url = configState.getBackendURL();
kl = new Klecks({ klecks ||= await import("klecks");
kl = new klecks.Embed({
embedUrl: url, embedUrl: url,
onSubmit: submitKlecksToComfyUI, onSubmit: submitKlecksToComfyUI,
targetEl: editorRoot, targetEl: editorRoot,