diff --git a/README.md b/README.md index 889f090..f78ecd6 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Also note that the saved workflow format is subject to change until it's been fi ## Features - **No-Code UI Builder** - A novel system for creating your own Stable Diffusion user interfaces from the basic components. -- **Manage Multiple Workflows** - You can open as many workflows as you like and switch between them using tabs within the app. +- **Manage Multiple Workflows** - You can open as many workflows as you like and switch between them using tabs within the app. You can also write custom "Send To" actions to copy your image outputs into other workflows for further processing. - **Use Your Existing Workflows** - Import workflows you've created in ComfyUI into ComfyBox and a new UI will be created for you. - **Extension Support** - All custom ComfyUI nodes are supported out of the box. - **Prompt Queue** - Queue up multiple prompts without waiting for them to finish first. Inspect currently queued and executed prompts. diff --git a/src/lib/nodes/widgets/ComfyMultiRegionNode.ts b/src/lib/nodes/widgets/ComfyMultiRegionNode.ts new file mode 100644 index 0000000..94de3af --- /dev/null +++ b/src/lib/nodes/widgets/ComfyMultiRegionNode.ts @@ -0,0 +1,154 @@ +import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core"; + +import MultiRegionWidget from "$lib/widgets/MultiRegionWidget.svelte"; +import ComfyWidgetNode, { type ComfyWidgetProperties } from "./ComfyWidgetNode"; +import { clamp } from "$lib/utils"; +import { writable, type Writable } from "svelte/store"; + +/* x, y, width, height, all in range 0.0 - 1.0 */ +export type BoundingBox = [number, number, number, number] + +function isBoundingBox(param: any): param is BoundingBox { + return Array.isArray(param) && param.length === 4 && param.every(i => typeof i === "number") +} + +export interface ComfyMultiRegionProperties extends ComfyWidgetProperties { + regionCount: number, + totalWidth: number, + totalHeight: number, + inputType: "size" | "image" +} + +export default class ComfyMultiRegionNode extends ComfyWidgetNode { + override properties: ComfyMultiRegionProperties = { + tags: [], + defaultValue: false, + regionCount: 1, + totalWidth: 512, + totalHeight: 512, + inputType: "size" + } + + static slotLayout: SlotLayout = { + inputs: [ + { name: "store", type: BuiltInSlotType.ACTION }, + + // dynamic inputs, may be removed later + { name: "width", type: "number" }, + { name: "height", type: "number" }, + ], + outputs: [ + { name: "changed", type: BuiltInSlotType.EVENT }, + + // dynamic outputs, may be removed later + { name: "x1", type: "number" }, + { name: "y1", type: "number" }, + { name: "w1", type: "number" }, + { name: "h1", type: "number" }, + ] + } + + override svelteComponentType = MultiRegionWidget; + override defaultValue: BoundingBox[] = [[0.4, 0.4, 0.8, 0.2]]; + override outputSlotName = null; + override storeActionName = "store"; + override changedEventName = "changed"; + + sizeChanged: Writable = writable(true); + + override onPropertyChanged(property: any, value: any) { + if (property === "regionCount") { + this.updateRegions() + } + else if (property === "width" || property === "height") { + this.updateSize(); + } + } + + constructor(name?: string) { + super(name, [[0.4, 0.4, 0.8, 0.2]]) + } + + override onExecute() { + let width = this.getInputData(1) + let height = this.getInputData(2) + + if (width != null && height != null && width != this.properties.width && height != this.properties.height) { + this.properties.width = width; + this.properties.height = height; + this.updateSize(); + } + + const value = this.getValue(); + + for (let index = 0; index < this.properties.regionCount * 2; index += 2) { + const bbox = value[index] + if (bbox != null) { + const xOutput = this.outputs[index + 1] + if (xOutput != null) { + this.setOutputData(index + 1, bbox[0] * this.properties.width) + this.setOutputData(index + 2, bbox[1] * this.properties.height) + this.setOutputData(index + 3, bbox[2] * this.properties.width) + this.setOutputData(index + 4, bbox[3] * this.properties.height) + } + } + } + } + + private updateRegions() { + this.properties.regionCount = Math.max(this.properties.regionCount, 0); + + for (let index = this.outputs.length - 1; index >= 0; index--) { + if (this.outputs[index].type !== BuiltInSlotType.EVENT) { + this.removeOutput(index); + } + } + + for (let index = 0; index < this.properties.regionCount; index++) { + this.addOutput(`x${index + 1}`, "number") + this.addOutput(`y${index + 1}`, "number") + this.addOutput(`w${index + 1}`, "number") + this.addOutput(`h${index + 1}`, "number") + } + + this.setValue(this.getValue()) + } + + private updateSize(value?: BoundingBox[]): BoundingBox[] { + this.properties.width = Math.max(this.properties.width, 1); + this.properties.height = Math.max(this.properties.height, 1); + + value ||= this.getValue(); + + for (const bbox of value) { + bbox[0] = clamp(bbox[0], 0, 1 - bbox[2]); + bbox[1] = clamp(bbox[1], 0, 1 - bbox[3]); + bbox[2] = clamp(bbox[2], 0, 1 - bbox[1]) + bbox[3] = clamp(bbox[3], 0, 1 - bbox[2]) + } + + this.sizeChanged.set(true); + + return value + } + + override parseValue(param: any): BoundingBox[] { + if (param == null || this.properties.regionCount <= 0) + return [] + + if (isBoundingBox(param)) + return this.updateSize([param]) + + if (Array.isArray(param) && param.every(isBoundingBox)) + return this.updateSize(param.splice(0, this.properties.regionCount)) + + return null; + } +} + +LiteGraph.registerNodeType({ + class: ComfyMultiRegionNode, + title: "UI.MultiRegion", + desc: "Overlays one or more regions over a canvas of the given width/height", + type: "ui/multi_region" +}) diff --git a/src/lib/nodes/widgets/index.ts b/src/lib/nodes/widgets/index.ts index 496e62e..130149c 100644 --- a/src/lib/nodes/widgets/index.ts +++ b/src/lib/nodes/widgets/index.ts @@ -8,3 +8,4 @@ export { default as ComfyImageUploadNode } from "./ComfyImageUploadNode" export { default as ComfyRadioNode } from "./ComfyRadioNode" export { default as ComfyNumberNode } from "./ComfyNumberNode" export { default as ComfyTextNode } from "./ComfyTextNode" +export { default as ComfyMultiRegionNode } from "./ComfyMultiRegionNode" diff --git a/src/lib/widgets/ImageUploadWidget.svelte b/src/lib/widgets/ImageUploadWidget.svelte index 7c9b72a..f381361 100644 --- a/src/lib/widgets/ImageUploadWidget.svelte +++ b/src/lib/widgets/ImageUploadWidget.svelte @@ -15,6 +15,7 @@ import NumberInput from "$lib/components/NumberInput.svelte"; import type { ComfyImageEditorNode } from "$lib/nodes/widgets"; import { ImageViewer } from "$lib/ImageViewer"; + import { generateBlankCanvas, generateImageCanvas } from "./utils"; export let widget: WidgetLayout | null = null; export let isMobile: boolean = false; @@ -55,42 +56,6 @@ 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 DIRECTORY: ComfyUploadImageType = "input"; diff --git a/src/lib/widgets/MultiRegionWidget.svelte b/src/lib/widgets/MultiRegionWidget.svelte new file mode 100644 index 0000000..179e073 --- /dev/null +++ b/src/lib/widgets/MultiRegionWidget.svelte @@ -0,0 +1,424 @@ + + + + + +
+
+ +
+
+ {#each displayBoxes as dBox, i} +
onBoxMouseDown(e, i)} + > + + Warning: Region very large! + +
+ {/each} +
+
+
+ + diff --git a/src/lib/widgets/utils.ts b/src/lib/widgets/utils.ts index bb1458f..dcfbc4d 100644 --- a/src/lib/widgets/utils.ts +++ b/src/lib/widgets/utils.ts @@ -36,3 +36,39 @@ export function isHidden(widget: IDragItem) { return false; } + +export 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; +} + +export 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; + }); +} + +export 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]; +}