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]} - {firstImage.orig_name} + 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 @@
- + {#if showCloseButton} + + {/if}
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. - + Status: {status} + {#if uploadError} +
+ Upload error: {uploadError} +
+ {/if}
{/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; + }