From f66df94c36ac7932ce90baa27870a3d0d4b419fc Mon Sep 17 00:00:00 2001
From: space-nuko <24979496+space-nuko@users.noreply.github.com>
Date: Sat, 13 May 2023 14:54:07 -0500
Subject: [PATCH] Image editor
---
klecks | 2 +-
src/lib/components/ComfyApp.ts | 1 +
src/lib/components/ImageUpload.svelte | 55 +++++---
src/lib/components/Modal.svelte | 20 ++-
src/lib/nodes/ComfyActionNodes.ts | 10 +-
src/lib/nodes/ComfyImageCacheNode.ts | 12 +-
src/lib/nodes/ComfyWidgetNodes.ts | 41 +++---
src/lib/utils.ts | 88 ++++++++----
src/lib/widgets/ImageEditorWidget.svelte | 166 +++++++++++++++++------
9 files changed, 272 insertions(+), 123 deletions(-)
diff --git a/klecks b/klecks
index 78c61a0..a1cb806 160000
--- a/klecks
+++ b/klecks
@@ -1 +1 @@
-Subproject commit 78c61a032e1ef9ed57e7feda3d28b093e1c612fa
+Subproject commit a1cb8064a0d63414fdfb346810bd4ab90ed0918c
diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts
index 0b9d393..486e4c8 100644
--- a/src/lib/components/ComfyApp.ts
+++ b/src/lib/components/ComfyApp.ts
@@ -409,6 +409,7 @@ export default class ComfyApp {
}
setColor("IMAGE", "rebeccapurple")
+ setColor("COMFY_IMAGE_FILE", "chartreuse")
setColor(BuiltInSlotType.EVENT, "lightseagreen")
setColor(BuiltInSlotType.ACTION, "lightseagreen")
}
diff --git a/src/lib/components/ImageUpload.svelte b/src/lib/components/ImageUpload.svelte
index 2fd505c..ec1fd96 100644
--- a/src/lib/components/ImageUpload.svelte
+++ b/src/lib/components/ImageUpload.svelte
@@ -1,14 +1,13 @@
@@ -189,11 +204,11 @@
Icon={FileIcon}
float={label != ""}
/>
- {#if _value && _value.length > 0 && !pending_upload}
- {@const firstImage = _value[0]}
+ {#if value && value.length > 0 && !pending_upload}
+ {@const firstImage = value[0]}
-
})
+ import { Button } from "@gradio/button";
import { createEventDispatcher } from "svelte";
export let showModal; // boolean
+ export let closeOnClick = true; // boolean
+ export let showCloseButton = true; // boolean
let dialog; // HTMLDialogElement
@@ -11,7 +14,17 @@
$: if (dialog && showModal) dialog.showModal();
- function close() {
+ function close(e: Event) {
+ if (!closeOnClick) {
+ e.preventDefault();
+ e.stopPropagation();
+ return false
+ }
+
+ doClose()
+ }
+
+ function doClose() {
dialog.close();
dispatch("close")
}
@@ -21,13 +34,16 @@
diff --git a/src/lib/nodes/ComfyActionNodes.ts b/src/lib/nodes/ComfyActionNodes.ts
index c7e5cb2..b64e73e 100644
--- a/src/lib/nodes/ComfyActionNodes.ts
+++ b/src/lib/nodes/ComfyActionNodes.ts
@@ -8,7 +8,7 @@ import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode"
import type { ComfyWidgetNode, GalleryOutput, GalleryOutputEntry } from "./ComfyWidgetNodes";
import type { NotifyOptions } from "$lib/notify";
import type { FileData as GradioFileData } from "@gradio/upload";
-import { convertComfyOutputToGradio, uploadImageToComfyUI, type ComfyUploadImageAPIResponse } from "$lib/utils";
+import { convertComfyOutputToGradio, reuploadImageToComfyUI, type ComfyUploadImageAPIResponse } from "$lib/utils";
export class ComfyQueueEvents extends ComfyGraphNode {
static slotLayout: SlotLayout = {
@@ -638,10 +638,10 @@ export class ComfyUploadImageAction extends ComfyGraphNode {
type: this.properties.folderType || "output"
}
- this._promise = uploadImageToComfyUI(data)
- .then((json: ComfyUploadImageAPIResponse) => {
- console.debug("[UploadImageAction] Succeeded", json)
- this.properties.lastUploadedImageFile = json.name;
+ this._promise = reuploadImageToComfyUI(data, "input")
+ .then((entry: GalleryOutputEntry) => {
+ console.debug("[UploadImageAction] Succeeded", entry)
+ this.properties.lastUploadedImageFile = entry.filename;
this.triggerSlot(1, this.properties.lastUploadedImageFile);
this._promise = null;
})
diff --git a/src/lib/nodes/ComfyImageCacheNode.ts b/src/lib/nodes/ComfyImageCacheNode.ts
index 65910af..5ed6249 100644
--- a/src/lib/nodes/ComfyImageCacheNode.ts
+++ b/src/lib/nodes/ComfyImageCacheNode.ts
@@ -1,7 +1,7 @@
import { BuiltInSlotType, LiteGraph, type ITextWidget, type SlotLayout, clamp, type PropertyLayout, type IComboWidget, type SerializedLGraphNode } from "@litegraph-ts/core";
import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode";
-import type { GalleryOutput } from "./ComfyWidgetNodes";
-import { uploadImageToComfyUI, type ComfyUploadImageAPIResponse } from "$lib/utils";
+import type { GalleryOutput, GalleryOutputEntry } from "./ComfyWidgetNodes";
+import { reuploadImageToComfyUI, type ComfyUploadImageAPIResponse } from "$lib/utils";
export interface ComfyImageCacheNodeProperties extends ComfyGraphNodeProperties {
images: GalleryOutput | null,
@@ -171,11 +171,11 @@ export default class ComfyImageCacheNode extends ComfyGraphNode {
this.properties.filenames[newIndex] = { filename: null, status: "uploading" }
this.onPropertyChanged("filenames", this.properties.filenames)
- const promise = uploadImageToComfyUI(data)
- .then((json: ComfyUploadImageAPIResponse) => {
- console.debug("Gottem", json)
+ const promise = reuploadImageToComfyUI(data, "input")
+ .then((entry: GalleryOutputEntry) => {
+ console.debug("Gottem", entry)
if (lastGenNumber === this.properties.genNumber) {
- this.properties.filenames[newIndex] = { filename: json.name, status: "cached" }
+ this.properties.filenames[newIndex] = { filename: entry.filename, status: "cached" }
this.onPropertyChanged("filenames", this.properties.filenames)
}
else {
diff --git a/src/lib/nodes/ComfyWidgetNodes.ts b/src/lib/nodes/ComfyWidgetNodes.ts
index a461ab4..cea2548 100644
--- a/src/lib/nodes/ComfyWidgetNodes.ts
+++ b/src/lib/nodes/ComfyWidgetNodes.ts
@@ -4,7 +4,7 @@ import type { SvelteComponentDev } from "svelte/internal";
import { Watch } from "@litegraph-ts/nodes-basic";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import { writable, type Unsubscriber, type Writable, get } from "svelte/store";
-import { clamp, convertComfyOutputToGradio, range } from "$lib/utils"
+import { clamp, convertComfyOutputToGradio, range, type ComfyUploadImageType } from "$lib/utils"
import layoutState from "$lib/stores/layoutState";
import type { FileData as GradioFileData } from "@gradio/upload";
import queueState from "$lib/stores/queueState";
@@ -604,7 +604,7 @@ export type GalleryOutput = {
export type GalleryOutputEntry = {
filename: string,
subfolder: string,
- type: string
+ type: ComfyUploadImageType
}
export interface ComfyGalleryProperties extends ComfyWidgetProperties {
@@ -985,7 +985,7 @@ export type MultiImageData = FileNameOrGalleryData[];
export interface ComfyImageEditorNodeProperties extends ComfyWidgetProperties {
}
-export class ComfyImageEditorNode extends ComfyWidgetNode
{
+export class ComfyImageEditorNode extends ComfyWidgetNode {
override properties: ComfyImageEditorNodeProperties = {
defaultValue: [],
tags: [],
@@ -1000,7 +1000,7 @@ export class ComfyImageEditorNode extends ComfyWidgetNode {
}
override svelteComponentType = ImageEditorWidget;
- override defaultValue: MultiImageData = [];
+ override defaultValue: GalleryOutputEntry[] = [];
override outputIndex = null;
override changedIndex = null;
override storeActionName = "store";
@@ -1012,27 +1012,30 @@ export class ComfyImageEditorNode extends ComfyWidgetNode {
_value = null;
- override parseValue(value: any): MultiImageData {
- if (value == null) {
+ override parseValue(value: any): GalleryOutputEntry[] {
+ if (value == null)
return []
+
+ const isComfyImageSpec = (value: any): boolean => {
+ return value && typeof value === "object" && "filename" in value && "type" in value
}
- else if (typeof value === "string" && value !== "") { // Single filename
- const prevValue = get(this.value)
- prevValue.push(value)
- if (prevValue.length > 2)
- prevValue.splice(0, 1)
- return prevValue as MultiImageData
+
+ if (typeof value === "string") {
+ // Single filename
+ return [{ filename: value, subfolder: "", type: "input" }]
}
- else if (typeof value === "object" && "images" in value && value.images.length > 0) {
- const output = value as GalleryOutput
+ else if (isComfyImageSpec(value)) {
+ // Single ComfyUI file
return [value]
}
- else if (Array.isArray(value) && value.every(s => typeof s === "string")) {
- return value as MultiImageData
- }
- else {
- return []
+ else if (Array.isArray(value)) {
+ if (value.every(v => typeof v === "string"))
+ return value.map(filename => { return { filename, subfolder: "", type: "input" } })
+ else if (value.every(isComfyImageSpec))
+ return value
}
+
+ return []
}
override formatValue(value: GradioFileData[]): string {
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 6e06d7c..f160102 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -126,17 +126,19 @@ export const debounce = (callback: Function, wait = 250) => {
};
export function convertComfyOutputToGradio(output: GalleryOutput): GradioFileData[] {
- return output.images.map(r => {
- const url = `http://${location.hostname}:8188` // TODO make configurable
- const params = new URLSearchParams(r)
- const fileData: GradioFileData = {
- name: r.filename,
- orig_name: r.filename,
- is_file: false,
- data: url + "/view?" + params
- }
- return fileData
- });
+ return output.images.map(convertComfyOutputEntryToGradio);
+}
+
+export function convertComfyOutputEntryToGradio(r: GalleryOutputEntry): GradioFileData {
+ const url = `http://${location.hostname}:8188` // TODO make configurable
+ const params = new URLSearchParams(r)
+ const fileData: GradioFileData = {
+ name: r.filename,
+ orig_name: r.filename,
+ is_file: false,
+ data: url + "/view?" + params
+ }
+ return fileData
}
export function convertComfyOutputToComfyURL(output: FileNameOrGalleryData): string {
@@ -148,13 +150,13 @@ export function convertComfyOutputToComfyURL(output: FileNameOrGalleryData): str
return url + "/view?" + params
}
-export function converGradioFileDataToComfyURL(image: GradioFileData, type: "input" | "output" | "temp" = "input"): string {
+export function convertGradioFileDataToComfyURL(image: GradioFileData, type: ComfyUploadImageType = "input"): string {
const baseUrl = `http://${location.hostname}:8188` // TODO make configurable
const params = new URLSearchParams({ filename: image.name, subfolder: "", type })
return `${baseUrl}/view?${params}`
}
-export function convertGradioFileDataToComfyOutput(fileData: GradioFileData, type: "input" | "output" | "temp" = "input"): GalleryOutputEntry {
+export function convertGradioFileDataToComfyOutput(fileData: GradioFileData, type: ComfyUploadImageType = "input"): GalleryOutputEntry {
if (!fileData.is_file)
throw "Can't convert blob data to comfy output!"
@@ -191,26 +193,58 @@ export function jsonToJsObject(json: string): string {
});
}
+export type ComfyUploadImageType = "output" | "input" | "temp"
+
export interface ComfyUploadImageAPIResponse {
- name: string
+ name: string, // Yes this is different from the "executed" event args
+ subfolder: string,
+ type: ComfyUploadImageType
}
-export async function uploadImageToComfyUI(data: GalleryOutputEntry): Promise {
+/*
+ * Uploads an image into ComfyUI's `input` folder.
+ */
+export async function uploadImageToComfyUI(blob: Blob, filename: string, type: ComfyUploadImageType, subfolder: string = "", overwrite: boolean = false): Promise {
+ console.debug("[utils] Uploading image to ComfyUI", filename, blob.size)
+
+ const url = `http://${location.hostname}:8188` // TODO make configurable
+
+ const formData = new FormData();
+ formData.append("image", blob, filename);
+ formData.set("type", type)
+ formData.set("subfolder", subfolder)
+ formData.set("overwrite", String(overwrite))
+
+ const req = new Request(url + "/upload/image", {
+ body: formData,
+ method: 'POST'
+ });
+
+ return fetch(req)
+ .then((r) => r.json())
+ .then((resp) => {
+ return {
+ filename: resp.name,
+ subfolder: resp.subfolder,
+ type: resp.type
+ }
+ });
+}
+
+/*
+ * Copies an *EXISTING* image in a ComfyUI image folder into a different folder,
+ * for use with LoadImage etc.
+ */
+export async function reuploadImageToComfyUI(data: GalleryOutputEntry, type: ComfyUploadImageType): Promise {
+ if (data.type === type)
+ return data
+
const url = `http://${location.hostname}:8188` // TODO make configurable
const params = new URLSearchParams(data)
+ console.debug("[utils] Reuploading image into to ComfyUI input folder", data)
+
return fetch(url + "/view?" + params)
.then((r) => r.blob())
- .then((blob) => {
- console.debug("Fetchin", url, params)
- const formData = new FormData();
- formData.append("image", blob, data.filename);
- return fetch(
- new Request(url + "/upload/image", {
- body: formData,
- method: 'POST'
- })
- )
- })
- .then((r) => r.json())
+ .then((blob) => uploadImageToComfyUI(blob, data.filename, type))
}
diff --git a/src/lib/widgets/ImageEditorWidget.svelte b/src/lib/widgets/ImageEditorWidget.svelte
index 88a6652..85b2ae9 100644
--- a/src/lib/widgets/ImageEditorWidget.svelte
+++ b/src/lib/widgets/ImageEditorWidget.svelte
@@ -10,13 +10,13 @@
import "klecks/style/style.scss";
import ImageUpload from "$lib/components/ImageUpload.svelte";
- import { uploadImageToComfyUI, type ComfyUploadImageAPIResponse } from "$lib/utils";
+ import { uploadImageToComfyUI, type ComfyUploadImageAPIResponse, convertComfyOutputToComfyURL } from "$lib/utils";
import notify from "$lib/notify";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyImageEditorNode | null = null;
- let nodeValue: Writable | null = null;
+ let nodeValue: Writable | null = null;
let attrsChanged: Writable | null = null;
let leftUrl: string = ""
let rightUrl: string = ""
@@ -63,6 +63,8 @@
let kl: Klecks | null = null;
function disposeEditor() {
+ console.warn("[ImageEditorWidget] CLOSING", widget, $nodeValue)
+
if (editorRoot) {
while (editorRoot.firstChild) {
editorRoot.removeChild(editorRoot.firstChild);
@@ -73,25 +75,52 @@
showModal = false;
}
+ function generateBlankCanvas(width: number, height: number, fill: string = "#fff"): HTMLCanvasElement {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ ctx.save();
+ ctx.fillStyle = fill,
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.restore();
+ return canvas;
+ }
+
+ async function loadImage(imageURL: string): Promise {
+ return new Promise((resolve) => {
+ const e = new Image();
+ e.setAttribute('crossorigin', 'anonymous'); // Don't taint the canvas from loading files on-disk
+ e.addEventListener("load", () => { resolve(e); });
+ e.src = imageURL;
+ return e;
+ });
+ }
+
+ async function generateImageCanvas(imageURL: string): Promise<[HTMLCanvasElement, number, number]> {
+ const image = await loadImage(imageURL);
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width;
+ canvas.height = image.height;
+ const ctx = canvas.getContext('2d');
+ ctx.save();
+ ctx.fillStyle = "rgba(255, 255, 255, 0.0)";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(image, 0, 0);
+ ctx.restore();
+ return [canvas, image.width, image.height];
+ }
+
const FILENAME: string = "ComfyUITemp.png";
- const SUBFOLDER: string = "ComfyBox_Editor";
+ // const SUBFOLDER: string = "ComfyBox_Editor";
async function submitKlecksToComfyUI(onSuccess: () => void, onError: () => void) {
const blob = kl.getPNG();
- const formData = new FormData();
- formData.append("image", blob, FILENAME);
-
- const entry: GalleryOutputEntry = {
- filename: FILENAME,
- subfolder: SUBFOLDER,
- type: "input"
- }
-
- await uploadImageToComfyUI(entry)
- .then((resp: ComfyUploadImageAPIResponse) => {
- entry.filename = resp.name;
- $nodeValue = [entry]
+ await uploadImageToComfyUI(blob, FILENAME, "input")
+ .then((entry: GalleryOutputEntry) => {
+ $nodeValue = [entry] // TODO more than one image
+ notify("Saved image to ComfyUI!", { type: "success" })
onSuccess();
})
.catch(err => {
@@ -101,19 +130,7 @@
})
}
- function generateBlankImage(fill: string = "#fff"): HTMLCanvasElement {
- const canvas = document.createElement('canvas');
- canvas.width = 512;
- canvas.height = 512;
- const ctx = canvas.getContext('2d');
- ctx.save();
- ctx.fillStyle = fill,
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- ctx.restore();
- return canvas;
- }
-
- function openImageEditor() {
+ async function openImageEditor() {
if (!editorRoot)
return;
@@ -124,17 +141,33 @@
kl = new Klecks({
embedUrl: url,
onSubmit: submitKlecksToComfyUI,
- targetEl: editorRoot.parentElement.parentElement
+ targetEl: editorRoot,
+ warnOnPageClose: false
});
+ console.warn("[ImageEditorWidget] OPENING", widget, $nodeValue)
+
+ let canvas = null;
+ let width = 512;
+ let height = 512;
+
+ if ($nodeValue && $nodeValue.length > 0) {
+ const comfyImage = $nodeValue[0];
+ const comfyURL = convertComfyOutputToComfyURL(comfyImage);
+ [canvas, width, height] = await generateImageCanvas(comfyURL);
+ }
+ else {
+ canvas = generateBlankCanvas(width, height);
+ }
+
kl.openProject({
- width: 512,
- height: 512,
+ width: width,
+ height: height,
layers: [{
name: 'Image',
opacity: 1,
mixModeStr: 'source-over',
- image: generateBlankImage(),
+ image: canvas
}]
});
@@ -143,24 +176,52 @@
}, 1000);
}
- function onUploadChanged(e: CustomEvent) {
+ let status = "none"
+ let uploadError = null;
+ function onUploading() {
+ uploadError = null;
+ status = "uploading"
}
+
+ function onUploaded(e: CustomEvent) {
+ uploadError = null;
+ status = "uploaded"
+ $nodeValue = e.detail;
+ }
+
+ function onClear() {
+ uploadError = null;
+ status = "none"
+ }
+
+ function onUploadError(e: CustomEvent) {
+ status = "error"
+ uploadError = e.detail
+ }
+
+ function onChange(e: CustomEvent) {
+ // $nodeValue = e.detail;
+ }
+
+ $: canEdit = status === "none" || status === "uploaded";
+
{#if isMobile}
TODO mask editor
{:else}
-
-
-
+
+
-
- Image editor.
-
{/if}
@@ -185,6 +255,12 @@
width: 75vw;
height: 75vh;
overflow: hidden;
+
+ color: black;
+
+ :global(> .g-root) {
+ height: calc(100% - 59px);
+ }
}
.comfy-image-editor {
@@ -192,4 +268,8 @@
overflow: hidden;
}
}
+
+ :global(.kl-popup) {
+ z-index: 999999999999;
+ }