21 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
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
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
27 changed files with 17772 additions and 348 deletions

File diff suppressed because it is too large Load Diff

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";
@@ -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)
notify(`Execution error: ${message}`, { type: "error", timeout: 10000 })
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();
@@ -971,6 +997,7 @@ export default class ComfyApp {
if ("getPromptThumbnails" in node) {
const thumbsToAdd = (node as ComfyGraphNode).getPromptThumbnails();
console.warn("THUMBNAILS", thumbsToAdd)
if (thumbsToAdd)
thumbnails.push(...thumbsToAdd)
}
@@ -1006,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,
@@ -1017,22 +1045,36 @@ export default class ComfyApp {
try {
const response = await this.api.queuePrompt(request);
if (response.error != null) {
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: any = 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;
}
@@ -1254,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,18 +79,22 @@
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 (paneNode) {
(paneNode as HTMLElement).ontransitionstart = () => {
$interfaceState.graphTransitioning = true
}
(paneNode as HTMLElement).ontransitionend = () => {
$interfaceState.graphTransitioning = false
app.resizeCanvas()
}
if (!canvas)
return null;
return canvas.closest(".splitpanes__pane")
}
$: if (containerElem) {
const paneNode = getGraphPane();
if (paneNode) {
(paneNode as HTMLElement).ontransitionstart = () => {
$interfaceState.graphTransitioning = true
}
(paneNode as HTMLElement).ontransitionend = () => {
$interfaceState.graphTransitioning = false
app.resizeCanvas()
}
}
}
@@ -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>
{/if}
<span class="left">
<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

@@ -71,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!")
@@ -84,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!")
@@ -100,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);
@@ -113,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];
}
/*
@@ -133,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);
@@ -152,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)
@@ -176,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,9 +146,11 @@
if (entry.extraData?.workflowTitle != null) {
message = `${entry.extraData.workflowTitle}`
}
const subgraphsString = subgraphs.join(', ')
if (subgraphsString.length > 0)
if (subgraphs && subgraphs.length > 0) {
const subgraphsString = subgraphs.join(', ')
message += ` (${subgraphsString})`
}
let submessage = `Nodes: ${Object.keys(entry.prompt).length}`
@@ -198,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;
}
@@ -232,10 +236,20 @@
let selectedPrompt = null;
let selectedImages = [];
function showPrompt(entry: QueueUIEntry) {
selectedPrompt = entry.entry.prompt;
selectedImages = entry.images;
showModal = true;
expandAll = false
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() {

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

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

@@ -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,13 +42,21 @@ 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[] {

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

View File

@@ -1,12 +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 { get, writable, type Writable } from "svelte/store";
import workflowState from "./workflowState";
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,
@@ -15,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
}
/*
@@ -34,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,
@@ -46,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>,
}
/*
@@ -61,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
}
/*
@@ -229,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 {
@@ -314,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 {
@@ -432,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,
@@ -447,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;
@@ -63,6 +65,21 @@ export type WorkflowAttributes = {
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.
@@ -89,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)
}
@@ -257,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;
@@ -420,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,
@@ -434,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: []
}
}
@@ -628,3 +639,70 @@ export function playSound(sound: string) {
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 { ImageViewer } from "$lib/ImageViewer";
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,64 +222,50 @@
/>
{:else}
<div class="comfy-image-editor-panel">
<ImageUpload value={_value}
bind:imgWidth={$imgWidth}
bind:imgHeight={$imgHeight}
fileCount={"single"}
elem_classes={[]}
style={""}
label={widget.attrs.title}
on:uploading={onUploading}
on:uploaded={onUploaded}
on:upload_error={onUploadError}
on:clear={onClear}
on:change={onChange}
on:image_clicked={openLightbox}
/>
<Modal bind:showModal closeOnClick={false} on:close={disposeEditor} bind:closeDialog>
<div>
<div id="klecks-loading-screen">
<span id="klecks-loading-screen-text"></span>
</div>
<div class="image-editor-root" bind:this={editorRoot} />
{#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>
<div slot="buttons">
<Button variant="primary" on:click={saveAndClose}>
Save and Close
</Button>
<Button variant="secondary" on:click={closeDialog}>
Discard Edits
</Button>
</div>
</Modal>
{/if}
<div style:display={(canMask && editMask) ? "none" : "block"}>
<ImageUpload value={_value}
bind:imgWidth={$imgWidth}
bind:imgHeight={$imgHeight}
fileCount={"single"}
elem_classes={[]}
style={""}
label={widget.attrs.title}
on:uploading={onUploading}
on:uploaded={onUploaded}
on:upload_error={onUploadError}
on:clear={onClear}
on:change={onChange}
on:image_clicked={openLightbox}
/>
</div>
<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
</Button>
{#if canMask}
<div>
<TextBox show_label={false} disabled={true} value="Status: {status}"/>
{#if editMask}
<Button variant="secondary" on:click={() => { clearMask(); notify("Mask cleared."); }}>
Clear Mask
</Button>
{/if}
<Button disabled={!_value} on:click={toggleEditMask}>
{#if editMask}
Show Image
{:else}
Edit Mask
{/if}
</Button>
</div>
{#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>
{/if}
<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>