34 Commits

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

View File

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

View File

@@ -3331,17 +3331,17 @@
"title": "UI.Text",
"properties": {
"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,
"lines": 5,
"maxLines": 5
},
"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",
"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": {},
"saveUserState": true
},
@@ -3396,17 +3396,17 @@
"title": "UI.Text",
"properties": {
"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,
"lines": 5,
"maxLines": 5
},
"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",
"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": {},
"saveUserState": true
},
@@ -3461,17 +3461,17 @@
"title": "UI.Text",
"properties": {
"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,
"lines": 5,
"maxLines": 5
},
"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",
"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": {},
"saveUserState": true
},
@@ -3526,17 +3526,17 @@
"title": "UI.Text",
"properties": {
"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,
"lines": 5,
"maxLines": 5
},
"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",
"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": {},
"saveUserState": true
},
@@ -4706,17 +4706,17 @@
"title": "UI.Text",
"properties": {
"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,
"lines": 5,
"maxLines": 5
},
"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",
"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": {},
"saveUserState": true
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -285,6 +285,19 @@ function relocateNodes(nodes: SerializedLGraphNode[]): SerializedLGraphNode[] {
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[]] {
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; });
nodes = relocateNodes(nodes);
nodes = stripTags(nodes);
[nodes, links] = pruneDetachedLinks(nodes, links);
const svg = renderSvg(canvas, graph, TEMPLATE_SVG_PADDING);

View File

@@ -249,6 +249,11 @@ export default class ComfyGraph extends LGraph {
node_data.id = uuidv4();
templateNodeIDToNewNode[prevNodeId] = node
// Strip tags from top-level nodes
if (Array.isArray(node_data.properties.tags)) {
node_data.properties.tags = []
}
node.configure(node_data);
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 { createTemplate, serializeTemplate, type ComfyBoxTemplate, type SerializedComfyBoxTemplate } from "./ComfyBoxTemplate";
import type ComfyGraph from "./ComfyGraph";
import type { ComfyGraphErrorLocation, ComfyGraphErrors, ComfyNodeErrors } from "./apiErrors";
import type ComfyApp from "./components/ComfyApp";
import { ComfyReroute } from "./nodes";
import notify from "./notify";
import layoutStates, { type ContainerLayout } from "./stores/layoutStates";
import queueState from "./stores/queueState";
import selectionState from "./stores/selectionState";
import templateState from "./stores/templateState";
import { createTemplate, type ComfyBoxTemplate, serializeTemplate, type SerializedComfyBoxTemplate } from "./ComfyBoxTemplate";
import notify from "./notify";
import { calcNodesBoundingBox } from "./utils";
export type SerializedGraphCanvasState = {
@@ -20,11 +21,22 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
app: ComfyApp | null;
private _unsubscribe: Unsubscriber;
isExportingSVG: boolean = false;
activeErrors?: ComfyGraphErrors = null;
blinkError: ComfyGraphErrorLocation | null = null;
blinkErrorTime: number = 0;
highlightNodeAndInput: [LGraphNode, number] | null = null;
get comfyGraph(): ComfyGraph | null {
return this.graph as ComfyGraph;
}
clearErrors() {
this.activeErrors = null;
this.blinkError = null;
this.blinkErrorTime = 0;
this.highlightNodeAndInput = null;
}
constructor(
app: ComfyApp,
canvas: HTMLCanvasElement | string,
@@ -90,10 +102,18 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
let state = get(queueState);
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 thickness = 1;
let blink = false;
// if (this._selectedNodes.has(node.id)) {
// color = "yellow";
// thickness = 5;
@@ -104,6 +124,29 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
else if (isRunningNode) {
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) {
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.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) {
@@ -568,4 +659,61 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
}
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 EventEmitter from "events";
import type { ComfyImageLocation } from "$lib/utils";
import type { SerializedLGraph, UUID } from "@litegraph-ts/core";
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 { ComfyAPIPromptErrorResponse } from "./apiErrors";
export type ComfyPromptRequest = {
client_id?: string,
@@ -43,11 +44,12 @@ export type ComfyAPIHistoryItem = [
ComfyNodeID[] // good outputs
]
export type ComfyAPIPromptResponse = {
promptID?: PromptID,
error?: string
export type ComfyAPIPromptSuccessResponse = {
promptID: PromptID
}
export type ComfyAPIPromptResponse = ComfyAPIPromptSuccessResponse | ComfyAPIPromptErrorResponse
export type ComfyAPIHistoryEntry = {
prompt: ComfyAPIHistoryItem,
outputs: SerializedPromptOutputs
@@ -92,7 +94,8 @@ type ComfyAPIEvents = {
executed: (promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutputs) => void,
execution_start: (promptID: PromptID) => 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 {
@@ -201,8 +204,11 @@ export default class ComfyAPI {
case "execution_cached":
this.eventBus.emit("execution_cached", msg.data.prompt_id, msg.data.nodes);
break;
case "execution_interrupted":
this.eventBus.emit("execution_interrupted", msg.data);
break;
case "execution_error":
this.eventBus.emit("execution_error", msg.data.prompt_id, msg.data.message);
this.eventBus.emit("execution_error", msg.data);
break;
default:
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 { 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";
@@ -29,7 +29,7 @@ import queueState from "$lib/stores/queueState";
import selectionState from "$lib/stores/selectionState";
import uiState from "$lib/stores/uiState";
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 { tick } from "svelte";
import { type SvelteComponentDev } from "svelte/internal";
@@ -38,6 +38,7 @@ import ComfyPromptSerializer, { isActiveBackendNode, nodeHasTag, UpstreamNodeLoc
import DanbooruTags from "$lib/DanbooruTags";
import { deserializeTemplateFromSVG, type SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate";
import templateState from "$lib/stores/templateState";
import { formatValidationError, type ComfyAPIPromptErrorResponse, formatExecutionError, type ComfyExecutionError } from "$lib/apiErrors";
export const COMFYBOX_SERIAL_VERSION = 1;
@@ -91,7 +92,8 @@ export type SerializedAppState = {
}
/** [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>;
@@ -307,7 +309,7 @@ export default class ComfyApp {
if (errors && errors.length > 0)
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]
})
@@ -597,9 +599,33 @@ export default class ComfyApp {
queueState.executionCached(promptID, nodes)
});
this.api.addEventListener("execution_error", (promptID: PromptID, message: string) => {
queueState.executionError(promptID, message)
this.api.addEventListener("execution_error", (error: ComfyExecutionError) => {
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 })
}
});
this.api.init();
@@ -891,7 +917,7 @@ export default class ComfyApp {
if (workflow.attrs.queuePromptButtonRunWorkflow) {
// Hold control to queue at the front
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)
}
async queuePrompt(num: number, batchCount: number = 1, tag: string | null = null) {
const activeWorkflow = workflowState.getActiveWorkflow();
if (activeWorkflow == null) {
notify("No workflow is opened!", { type: "error" })
return;
}
this.queueItems.push({ num, batchCount, workflow: activeWorkflow });
async queuePrompt(targetWorkflow: ComfyBoxWorkflow, num: number, batchCount: number = 1, tag: string | null = null) {
this.queueItems.push({ num, batchCount, workflow: targetWorkflow });
// Only have one action process the items so each one gets a unique seed correctly
if (this.processingQueue) {
@@ -958,6 +978,10 @@ export default class ComfyApp {
if (tag === "")
tag = null;
if (targetWorkflow.attrs.showDefaultNotifications) {
notify("Prompt queued.", { type: "info" });
}
this.processingQueue = true;
let workflow: ComfyBoxWorkflow;
@@ -968,11 +992,12 @@ export default class ComfyApp {
const thumbnails = []
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;
if ("getPromptThumbnails" in node) {
const thumbsToAdd = (node as ComfyGraphNode).getPromptThumbnails();
console.warn("THUMBNAILS", thumbsToAdd)
if (thumbsToAdd)
thumbnails.push(...thumbsToAdd)
}
@@ -1008,8 +1033,9 @@ export default class ComfyApp {
thumbnails
}
let error: string | null = null;
let promptID: PromptID | null = null;
let error: ComfyAPIPromptErrorResponse | null = null;
let errorMes: string | null = null;
let errorPromptID: PromptID | null = null;
const request: ComfyPromptRequest = {
number: num,
@@ -1019,22 +1045,36 @@ export default class ComfyApp {
try {
const response = await this.api.queuePrompt(request);
if (response.error != null) {
error = response.error;
if ("error" in response) {
error = response;
errorMes = formatValidationError(error)
errorPromptID = queueState.promptError(workflow.id, response, p, extraData)
workflowState.promptError(workflow.id, errorPromptID)
}
else {
queueState.afterQueued(workflow.id, response.promptID, num, p.output, extraData)
workflowState.afterQueued(workflow.id, response.promptID, p, extraData)
}
} catch (err) {
error = err?.toString();
errorMes = err?.toString();
}
if (error != null) {
const mes: string = error;
notify(`Error queuing prompt: \n${mes} `, { type: "error" })
notify(
`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(promptToGraphVis(p))
console.error("Error queuing prompt", error, num, p)
}
else if (errorMes != null) {
break;
}
@@ -1256,7 +1296,7 @@ export default class ComfyApp {
let defaultValue = null;
if (foundInput != null) {
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;
defaultValue = comfyInput.config.defaultValue;
}

View File

@@ -1,3 +1,7 @@
<script context="module" lang="ts">
export const WORKFLOWS_VIEW: any = {}
</script>
<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes';
import { PlusSquareDotted } from 'svelte-bootstrap-icons';
@@ -12,12 +16,15 @@
import workflowState, { ComfyBoxWorkflow, type WorkflowInstID } from "$lib/stores/workflowState";
import selectionState from "$lib/stores/selectionState";
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 { fade } from 'svelte/transition';
import { cubicIn } from 'svelte/easing';
import { truncateString } from '$lib/utils';
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 uiTheme: string = "gradio-dark" // TODO config
@@ -44,6 +51,8 @@
}
);
refreshView();
lastError = null;
})
async function doRefreshCombos() {
@@ -70,10 +79,15 @@
let graphSize = 0;
$: if (containerElem) {
function getGraphPane(): HTMLDivElement | null {
const canvas = containerElem.querySelector<HTMLDivElement>("#graph-canvas")
if (canvas) {
const paneNode = canvas.closest(".splitpanes__pane")
if (!canvas)
return null;
return canvas.closest(".splitpanes__pane")
}
$: if (containerElem) {
const paneNode = getGraphPane();
if (paneNode) {
(paneNode as HTMLElement).ontransitionstart = () => {
$interfaceState.graphTransitioning = true
@@ -84,7 +98,6 @@
}
}
}
}
function queuePrompt() {
app.runDefaultQueueAction()
@@ -191,6 +204,77 @@
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>
<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">
import { Button } from "@gradio/button";
import { onMount } from "svelte";
import type ComfyApp from "./ComfyApp";
import DropZone from "./DropZone.svelte";
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;
let canvas: HTMLCanvasElement;
onMount(async () => {
if (app?.lCanvas) {
canvas = app.lCanvas.canvas;
app.lCanvas?.setCanvas(canvas)
}
})
function doRecenter(): void {
app?.lCanvas?.recenter();
}
function clearErrors(): void {
$uiState.activeError = null;
}
</script>
<div class="wrapper litegraph">
<div class="canvas-wrapper pane-wrapper">
<canvas id="graph-canvas" />
<canvas bind:this={canvas} id="graph-canvas" />
<DropZone {app} />
</div>
<div class="bar">
{#if !$interfaceState.graphTransitioning}
<span class="left">
<button on:click={doRecenter}>Recenter</button>
</span>
<button disabled={$interfaceState.graphTransitioning} on:click={doRecenter}>Recenter</button>
{#if $uiState.activeError != null}
<button disabled={$interfaceState.graphTransitioning} on:click={clearErrors}>Clear Errors</button>
{/if}
</span>
</div>
{#if $uiState.activeError && app?.lCanvas?.activeErrors != null}
<ComfyGraphErrorList {app} errors={app.lCanvas.activeErrors} />
{/if}
</div>
<style lang="scss">
@@ -73,6 +93,9 @@
background-color: #555;
border-color: #777;
}
&:disabled {
opacity: 50%;
}
}
}
</style>

View File

@@ -15,18 +15,17 @@ function isGraphInputOutput(node: LGraphNode): boolean {
return node.is(GraphInput) || node.is(GraphOutput)
}
export function nodeHasTag(node: LGraphNode, tag: string): 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;
export function nodeHasTag(node: LGraphNode, tag: string, checkParents: boolean): boolean {
while (node != null) {
if ("tags" in node.properties) {
if (node.properties.tags.indexOf(tag) !== -1)
return true;
}
if (!checkParents) {
return false;
}
// Count parent subgraphs having the tag also.
node = node.graph?._subgraph_node;
}
@@ -38,8 +37,10 @@ export function isActiveNode(node: LGraphNode, tag: string | null = null): boole
if (!node)
return false;
// Check tags but not on graph inputs/outputs
if (!isGraphInputOutput(node) && (tag && !nodeHasTag(node, tag))) {
// Ignore tags on reroutes since they're just movable wires and it defeats
// 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)
return false;
}
@@ -70,11 +71,13 @@ export function isActiveBackendNode(node: LGraphNode, tag: string | null = null)
return true;
}
type UpstreamResult = [LGraph | null, LLink | null, number | null, LGraphNode | null];
export class UpstreamNodeLocator {
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)
throw new Error("Invalid link and graph output!")
@@ -83,10 +86,10 @@ export class UpstreamNodeLocator {
throw new Error("No inner graph input!")
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)
throw new Error("Invalid link and graph input!")
@@ -99,10 +102,10 @@ export class UpstreamNodeLocator {
throw new Error("No outer input slot!")
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)) {
console.debug("FollowSubgraph")
return this.followSubgraph(parent, currentLink);
@@ -112,17 +115,18 @@ export class UpstreamNodeLocator {
return this.followGraphInput(parent, currentLink);
}
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) {
// Only one input, so assume we can follow it backwards.
const link = parent.getInputLink(0);
if (link) {
return [parent.graph, link]
return [parent.graph, link, 0, parent]
}
}
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
* 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);
if (!parent)
return [null, null];
return [null, null, null, null];
const seen = {}
let currentLink = fromNode.getInputLink(inputIndex);
let currentInputSlot = inputIndex;
let currentNode = fromNode;
const shouldFollowParent = (parent: LGraphNode) => {
return isActiveNode(parent, tag) && !this.isTheTargetNode(parent);
@@ -151,7 +157,10 @@ export class UpstreamNodeLocator {
// nodes have conditional logic that determines which link
// to follow backwards.
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) {
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)
return [null, null];
return [null, currentLink, currentInputSlot, currentNode];
return [parent, currentLink]
return [parent, currentLink, currentInputSlot, currentNode]
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -103,6 +103,16 @@ export default class ComfyGraphNode extends LGraphNode {
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 {
return layoutStates.getLayoutByNode(this);
}
@@ -151,7 +161,7 @@ export default class ComfyGraphNode extends LGraphNode {
while (currentNode) {
updateNodes.unshift(currentNode);
const link = currentNode.getUpstreamLink();
const link = currentNode.getUpstreamLinkForInheritedType();
if (link !== null) {
const node = this.graph.getNodeById(link.origin_id) as ComfyGraphNode;
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 ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode";
import { Watch } from "@litegraph-ts/nodes-basic";
import { nextLetter } from "$lib/utils";
export type PickFirstMode = "anyActiveLink" | "truthy" | "dataNonNull"
@@ -8,17 +9,6 @@ export interface ComfyPickFirstNodeProperties extends ComfyGraphNodeProperties {
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 {
override properties: ComfyPickFirstNodeProperties = {
tags: [],

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ export default class ComfyExecuteSubgraphAction extends ComfyGraphNode {
// Hold control to queue at the front
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 ComfyGraphNode, { type ComfyGraphNodeProperties } from "../ComfyGraphNode";
import { playSound } from "$lib/utils";
export interface ComfyPlaySoundActionProperties extends ComfyGraphNodeProperties {
sound: string,
@@ -21,9 +22,7 @@ export default class ComfyPlaySoundAction extends ComfyGraphNode {
override onAction(action: any, param: any) {
const sound = this.getInputData(0) || this.properties.sound;
if (sound) {
const url = `${location.origin}/sound/${sound}`;
const audio = new Audio(url);
audio.play();
playSound(sound)
}
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -616,6 +616,19 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: true
},
// ImageUpload
{
name: "maskCount",
type: "number",
location: "nodeProps",
editable: true,
validNodeTypes: ["ui/image_upload"],
defaultValue: 0,
min: 0,
max: 8,
step: 1
},
// Radio
{
name: "defaultValue",
@@ -667,6 +680,13 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
location: "workflow",
editable: 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 { Progress, SerializedPromptInputsAll, SerializedPromptOutputs, WorkflowInstID } from "$lib/components/ComfyApp";
import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, ComfyExecutionError, ComfyNodeID, PromptID, QueueItemType } from "$lib/api";
import type { ComfyAPIPromptErrorResponse } from "$lib/apiErrors";
import type { Progress, SerializedPrompt, SerializedPromptInputsAll, SerializedPromptOutputs, } from "$lib/components/ComfyApp";
import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify";
import { playSound } from "$lib/utils";
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 = {
queueUpdated: (resp: ComfyAPIQueueResponse) => void,
@@ -13,13 +17,14 @@ type QueueStateOps = {
executionStart: (promptID: PromptID) => void,
executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => QueueEntry | null;
executionCached: (promptID: PromptID, nodes: ComfyNodeID[]) => void,
executionError: (promptID: PromptID, message: string) => void,
executionError: (error: ComfyExecutionError) => CompletedQueueEntry | null,
progressUpdated: (progress: Progress) => void
getQueueEntry: (promptID: PromptID) => QueueEntry | null;
afterQueued: (workflowID: WorkflowInstID, promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void
queueItemDeleted: (type: QueueItemType, id: PromptID) => void;
queueCleared: (type: QueueItemType) => void;
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,
queuedAt?: 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,
prompt: SerializedPromptInputsAll,
extraData: ComfyBoxPromptExtraData,
@@ -44,7 +53,7 @@ export type QueueEntry = {
/* Nodes of the workflow that have finished running so far. */
nodesRan: Set<ComfyNodeID>,
/* 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?: string,
/** 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)
}
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 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)
entry.finishedAt = new Date() // Now
queue.update(qp => { qp.splice(index, 1); return qp });
const completed: CompletedQueueEntry = { entry, status, message, error: workflowError }
state.queueCompleted.update(qc => {
const completed: CompletedQueueEntry = { entry, status, message, error }
qc.push(completed)
return qc
})
state.isInterrupting = false;
store.set(state)
return completed;
}
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)");
}
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")
}
else {
@@ -307,20 +332,22 @@ function executionCached(promptID: PromptID, nodes: ComfyNodeID[]) {
})
}
function executionError(promptID: PromptID, message: string) {
console.debug("[queueState] executionError", promptID, message)
function executionError(error: ComfyExecutionError): CompletedQueueEntry | null {
console.debug("[queueState] executionError", error)
let entry_ = null;
store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID);
const [index, entry, queue] = findEntryInPending(error.prompt_id);
if (entry != null) {
moveToCompleted(index, queue, "error", "Error executing", message)
entry_ = moveToCompleted(index, queue, "error", "Error executing", error)
}
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.runningNodeID = null;
return s
})
return entry_;
}
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 =
{
...store,
@@ -440,6 +504,7 @@ const queueStateStore: WritableQueueStateStore =
queueItemDeleted,
queueCleared,
getQueueEntry,
onExecuted
onExecuted,
promptError,
}
export default queueStateStore;

View File

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

View File

@@ -8,8 +8,10 @@ import layoutStates from './layoutStates';
import { v4 as uuidv4 } from "uuid";
import type ComfyGraphCanvas from '$lib/ComfyGraphCanvas';
import { blankGraph } from '$lib/defaultGraph';
import type { SerializedAppState } from '$lib/components/ComfyApp';
import type { SerializedAppState, SerializedPrompt } from '$lib/components/ComfyApp';
import type ComfyReceiveOutputNode from '$lib/nodes/actions/ComfyReceiveOutputNode';
import type { ComfyBoxPromptExtraData, PromptID } from '$lib/api';
import type { ComfyAPIPromptErrorResponse, ComfyExecutionError } from '$lib/apiErrors';
type ActiveCanvas = {
canvas: LGraphCanvas | null;
@@ -54,8 +56,30 @@ export type WorkflowAttributes = {
* Comfy.QueueEvents node.
*/
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 {
/*
* 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();
/*
* Completed queue entry ID that holds the last validation/execution error.
*/
lastError?: PromptID
get layout(): WritableLayoutStateStore | null {
return layoutStates.getLayout(this.id)
}
@@ -217,7 +246,7 @@ export class ComfyBoxWorkflow {
// this.#invokeExtensions("loadedGraphNode", node);
}
this.attrs = data.attrs;
this.attrs = { ...defaultWorkflowAttributes, ...data.attrs };
// Now restore the layout
// Subsequent added nodes will add the UI data to layoutState
@@ -250,7 +279,10 @@ type WorkflowStateOps = {
closeWorkflow: (canvas: ComfyGraphCanvas, index: number) => void,
closeAllWorkflows: (canvas: ComfyGraphCanvas) => void,
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;
@@ -413,6 +445,36 @@ function findReceiveOutputTargets(type: SlotType | SlotType[]): WorkflowReceiveO
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 =
{
...store,
@@ -427,6 +489,9 @@ const workflowStateStore: WritableWorkflowStateStore =
closeWorkflow,
closeAllWorkflows,
setActiveWorkflow,
findReceiveOutputTargets
findReceiveOutputTargets,
afterQueued,
promptError,
executionError,
}
export default workflowStateStore;

View File

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

View File

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

View File

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