Merge pull request #45 from space-nuko/mask-editor

Image editor
This commit is contained in:
space-nuko
2023-05-14 11:42:23 -05:00
committed by GitHub
34 changed files with 2911 additions and 1010 deletions

View File

@@ -53,7 +53,7 @@ jobs:
- name: Build
run: |
pnpm build:css
pnpm prebuild
pnpm build
- uses: actions/upload-artifact@v3

View File

@@ -52,7 +52,7 @@ jobs:
- name: Build
run: |
pnpm build:css
pnpm prebuild
pnpm build
- name: Test

4
.gitmodules vendored
View File

@@ -5,3 +5,7 @@
[submodule "litegraph"]
path = litegraph
url = https://github.com/space-nuko/litegraph.ts
[submodule "klecks"]
path = klecks
url = https://github.com/space-nuko/klecks
branch = comfybox

1
klecks Submodule

Submodule klecks added at a36de3203f

View File

@@ -1,5 +1,5 @@
{
"name": "web2",
"name": "ComfyBox",
"version": "0.0.1",
"private": true,
"scripts": {
@@ -13,6 +13,7 @@
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"svelte-check": "svelte-check",
"prebuild": "pnpm run build:css && pnpm --filter=klecks lang:build",
"build:css": "pollen -c gradio/js/theme/src/pollen.config.cjs && mv src/pollen.css node_modules/@gradio/theme/src"
},
"devDependencies": {
@@ -28,6 +29,7 @@
"svelte-dnd-action": "^0.9.22",
"typescript": "^5.0.3",
"vite": "^4.3.1",
"vite-plugin-glsl": "^1.1.2",
"vite-plugin-static-copy": "^0.14.0",
"vite-plugin-svelte-console-remover": "^1.0.10",
"vite-tsconfig-paths": "^4.0.8",
@@ -62,6 +64,7 @@
"framework7": "^8.0.3",
"framework7-svelte": "^8.0.3",
"img-comparison-slider": "^8.0.0",
"klecks": "workspace:*",
"pollen-css": "^4.6.2",
"radix-icons-svelte": "^1.2.1",
"svelte-feather-icons": "^4.0.0",
@@ -71,6 +74,7 @@
"svelte-tiny-virtual-list": "^2.0.5",
"tailwindcss": "^3.3.1",
"typed-emitter": "github:andywer/typed-emitter",
"uuid": "^9.0.0",
"vite-plugin-full-reload": "^1.0.5"
}
}

1670
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,4 @@ packages:
- 'gradio/js/*'
- 'gradio/client/js'
- 'litegraph/packages/*'
- 'klecks'

View File

@@ -1,4 +1,4 @@
import { BuiltInSlotShape, LGraph, LGraphCanvas, LGraphNode, LiteGraph, NodeMode, type MouseEventExt, type Vector2, type Vector4, TitleMode, type ContextMenuItem, type IContextMenuItem, Subgraph } from "@litegraph-ts/core";
import { BuiltInSlotShape, LGraph, LGraphCanvas, LGraphNode, LiteGraph, NodeMode, type MouseEventExt, type Vector2, type Vector4, TitleMode, type ContextMenuItem, type IContextMenuItem, Subgraph, LLink } from "@litegraph-ts/core";
import type ComfyApp from "./components/ComfyApp";
import queueState from "./stores/queueState";
import { get } from "svelte/store";
@@ -259,66 +259,59 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
const newNode = LiteGraph.createNode(node.type);
for (let index = 0; index < newNode.inputs.length; index++) {
const newInput = newNode.inputs[index];
const oldInput = node.inputs[index]
if (oldInput && newInput.type === oldInput.type) {
continue;
}
let link: LLink | null = null;
if (oldInput) {
link = node.getInputLink(index);
node.disconnectInput(index)
oldInput.type = newInput.type
oldInput.name = newInput.name
}
else {
node.addInput(newInput.name, newInput.type, newInput)
}
const createInputReroute = (slotIndex: number, link: LLink | null): ComfyReroute => {
const reroute = LiteGraph.createNode(ComfyReroute);
reroute.properties.ignoreTypes = true;
node.graph.add(reroute)
const inputPos = node.getConnectionPos(true, index);
const inputPos = node.getConnectionPos(true, slotIndex);
reroute.pos = [inputPos[0] - 140, inputPos[1] + LiteGraph.NODE_SLOT_HEIGHT / 2];
reroute.connect(0, node, index);
if (link != null)
node.graph.getNodeById(link.target_id).connect(link.target_slot, reroute, 0)
node.graph.getNodeById(link.origin_id).connect(link.origin_slot, reroute, 0)
return reroute
}
for (let index = 0; index < newNode.outputs.length; index++) {
const newOutput = newNode.outputs[index];
const oldOutput = node.outputs[index]
for (let index = node.inputs.length - 1; index >= 0; index--) {
let link: LLink | null = null;
if (oldOutput && newOutput.type === oldOutput.type) {
continue;
link = node.getInputLink(index);
node.disconnectInput(index)
if (link)
createInputReroute(index, link);
node.removeInput(index);
}
let links = []
if (oldOutput) {
links = node.getOutputLinks(index)
node.disconnectOutput(index)
oldOutput.type = newOutput.type
oldOutput.name = newOutput.name
}
else {
node.addOutput(newOutput.name, newOutput.type, newOutput)
for (let index = 0; index < newNode.inputs.length; index++) {
const newInput = newNode.inputs[index]
const input = node.addInput(newInput.name, newInput.type);
}
const createOutputReroute = (index: number, links: LLink[], connect: boolean = true): ComfyReroute => {
const reroute = LiteGraph.createNode(ComfyReroute);
reroute.properties.ignoreTypes = true;
node.graph.add(reroute)
const rerouteSize = reroute.computeSize();
const outputPos = node.getConnectionPos(false, index);
reroute.pos = [outputPos[0] + rerouteSize[0] + 20, outputPos[1] + LiteGraph.NODE_SLOT_HEIGHT / 2];
node.connect(index, reroute, 0);
for (const link of links) {
reroute.connect(0, link.target_id, link.target_slot)
reroute.connect(0, node.graph.getNodeById(link.target_id), link.target_slot)
}
return reroute
}
for (let index = node.outputs.length - 1; index >= 0; index--) {
let links = node.getOutputLinks(index)
node.disconnectOutput(index)
if (links.length > 0)
createOutputReroute(index, links);
node.removeOutput(index);
}
for (let index = 0; index < newNode.outputs.length; index++) {
const newOutput = newNode.outputs[index]
const output = node.addOutput(newOutput.name, newOutput.type);
}
}

View File

@@ -1,7 +1,7 @@
import type { Progress, SerializedPrompt, SerializedPromptInputs, SerializedPromptInputsAll, SerializedPromptOutput, SerializedPromptOutputs } from "./components/ComfyApp";
import type TypedEmitter from "typed-emitter";
import EventEmitter from "events";
import type { GalleryOutput, GalleryOutputEntry } from "./nodes/ComfyWidgetNodes";
import type { ComfyExecutionResult, ComfyImageLocation } from "./nodes/ComfyWidgetNodes";
import type { SerializedLGraph, UUID } from "@litegraph-ts/core";
import type { SerializedLayoutState } from "./stores/layoutState";
@@ -63,7 +63,7 @@ export type ComfyPromptPNGInfo = {
}
export type ComfyBoxPromptExtraData = ComfyUIPromptExtraData & {
thumbnails?: GalleryOutputEntry[],
thumbnails?: ComfyImageLocation[],
}
export type ComfyUIPromptExtraData = {
@@ -258,9 +258,13 @@ export default class ComfyAPI {
},
body: postBody
})
.then(res => res.json())
.then(async (res) => {
if (res.status != 200) {
throw await res.text()
}
return res.json()
})
.then(raw => { return { promptID: raw.prompt_id } })
.catch(res => { throw res.text() })
.catch(error => { return { error } })
}

View File

@@ -32,7 +32,7 @@ import { download, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis }
import notify from "$lib/notify";
import configState from "$lib/stores/configState";
import { blankGraph } from "$lib/defaultGraph";
import type { GalleryOutput } from "$lib/nodes/ComfyWidgetNodes";
import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes";
export const COMFYBOX_SERIAL_VERSION = 1;
@@ -71,7 +71,7 @@ export type SerializedPrompt = {
output: SerializedPromptInputsAll
}
export type SerializedPromptOutputs = Record<NodeID, GalleryOutput>
export type SerializedPromptOutputs = Record<NodeID, ComfyExecutionResult>
export type Progress = {
value: number,
@@ -245,7 +245,7 @@ export default class ComfyApp {
const node: LGraphNodeConstructor = {
class: ctor,
title: nodeData.name,
title: nodeData.display_name || nodeData.name,
type: nodeId,
desc: `ComfyNode: ${nodeId}`
}
@@ -347,7 +347,7 @@ export default class ComfyApp {
this.lGraph.setDirtyCanvas(true, false);
});
this.api.addEventListener("executed", (promptID: PromptID, nodeID: NodeID, output: GalleryOutput) => {
this.api.addEventListener("executed", (promptID: PromptID, nodeID: NodeID, output: ComfyExecutionResult) => {
this.nodeOutputs[nodeID] = output;
const node = this.lGraph.getNodeById(nodeID) as ComfyGraphNode;
if (node?.onExecuted) {
@@ -408,7 +408,8 @@ export default class ComfyApp {
setColor(type, "orange")
}
setColor("IMAGE", "rebeccapurple")
setColor("COMFYBOX_IMAGES", "rebeccapurple")
setColor("COMFYBOX_IMAGE", "fuchsia")
setColor(BuiltInSlotType.EVENT, "lightseagreen")
setColor(BuiltInSlotType.ACTION, "lightseagreen")
}
@@ -785,15 +786,15 @@ export default class ComfyApp {
queueState.afterQueued(promptID, num, p.output, extraData)
error = response.error;
} catch (error) {
error = error.toString();
} catch (err) {
error = err
}
if (error != null) {
const mes = error.response || error.toString()
notify(`Error queuing prompt:\n${mes}`, { type: "error" })
console.error(promptToGraphVis(p))
console.error("Error queuing prompt", mes, num, p)
console.error("Error queuing prompt", error, num, p)
break;
}

View File

@@ -339,6 +339,7 @@
border-bottom: 1px solid var(--block-border-color);
border-top: 1px solid var(--table-border-color);
background: var(--panel-background-fill);
max-height: 14rem;
&:hover:not(:has(img:hover)) {
cursor: pointer;
@@ -387,6 +388,7 @@
column-gap: 1px;
row-gap: 1px;
vertical-align: top;
flex: 1 1 40%;
img {
aspect-ratio: 1 / 1;

View File

@@ -0,0 +1,254 @@
<script lang="ts">
import UploadText from "$lib/components/gradio/app/UploadText.svelte";
import type { ComfyImageLocation } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify";
import { convertComfyOutputEntryToGradio, convertComfyOutputToComfyURL, type ComfyUploadImageAPIResponse } from "$lib/utils";
import { Block, BlockLabel } from "@gradio/atoms";
import { File as FileIcon } from "@gradio/icons";
import type { FileData as GradioFileData } from "@gradio/upload";
import { ModifyUpload, Upload } from "@gradio/upload";
import { createEventDispatcher, tick } from "svelte";
export let value: ComfyImageLocation[] | null = null;
export let imgWidth: number = 0;
export let imgHeight: number = 0;
export let imgElem: HTMLImageElement | null = null
export let fileCount: "single" | "multiple" = "single"
export let elem_classes: string[] = []
export let style: string = ""
export let label: string = ""
// let propsChanged: Writable<number> | null = null;
let dragging = false;
let pending_upload = false;
let old_value: GradioFileData[] | null = null;
let _value: GradioFileData[] | null = null;
const root = "comf"
const root_url = "https//ComfyUI!"
let uploaded: boolean = false;
const dispatch = createEventDispatcher<{
change: ComfyImageLocation[];
uploading: undefined;
uploaded: ComfyImageLocation[];
upload_error: any;
clear: undefined;
image_clicked: undefined;
}>();
if (value) {
_value = null
if (imgElem)
imgElem.src = convertComfyOutputToComfyURL(value[0])
}
$: if (!(_value && _value.length > 0 && imgElem)) {
imgWidth = 1
imgHeight = 1
}
function onChange() {
dispatch("change", value)
}
function onUpload() {
dispatch("uploaded")
}
function onClear() {
dispatch("clear")
}
function onImgClicked() {
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);
dispatch("uploading")
const url = `http://${location.hostname}:8188` // TODO make configurable
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.cause)
}
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 }
})
}
$: {
if (JSON.stringify(_value) !== JSON.stringify(old_value) || uploaded) {
uploaded = false;
pending_upload = true;
old_value = _value;
if (_value == null)
_value = []
else if (!Array.isArray(_value))
_value = [_value]
const allBlobs = _value.map((file_data: GradioFileData) => file_data.blob)
if (allBlobs == null || allBlobs.length === 0) {
_value = null;
value = null;
onChange();
pending_upload = false;
}
else if (!allBlobs.every(b => b != null)) {
_value = null;
value = null;
onChange();
pending_upload = false;
}
else {
let files = (Array.isArray(_value) ? _value : [_value]).map(
(file_data) => file_data.blob!
);
let upload_value = _value;
pending_upload = true;
upload_files(root, 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)
return;
}
pending_upload = false;
if (response.error) {
notify(response.error, { type: "error" })
}
value = response.files;
dispatch("change", value)
dispatch("uploaded", value)
}).
catch(err => {
dispatch("upload_error", err)
});
}
}
}
async function handle_upload({ detail }: CustomEvent<GradioFileData | Array<GradioFileData>>) {
// Received Gradio-format file data from the Upload component.
// In the reactive block above it will be uploaded to ComfyUI.
_value = Array.isArray(detail) ? detail : [detail];
uploaded = true;
}
function handle_clear(_e: CustomEvent<null>) {
_value = null;
value = [];
dispatch("change", value)
dispatch("clear")
}
function convertGradioUpload(e: CustomEvent<GradioFileData[]>) {
_value = e.detail
}
</script>
<div class="image-upload" {style}>
{#if value}
<Block
visible={true}
variant={(value === null || value.length === 0) ? "dashed" : "solid"}
border_mode={dragging ? "focus" : "base"}
padding={true}
elem_id="comfy-image-upload-block"
{elem_classes}
>
<BlockLabel
label={label}
show_label={label != ""}
Icon={FileIcon}
float={label != ""}
/>
{#if value && value.length > 0 && !pending_upload}
{@const firstImage = value[0]}
<ModifyUpload on:clear={handle_clear} absolute />
<img src={convertComfyOutputToComfyURL(firstImage)}
alt={firstImage.filename}
on:click={onImgClicked}
bind:this={imgElem}
bind:naturalWidth={imgWidth}
bind:naturalHeight={imgHeight}
/>
{:else}
<Upload
file_count={fileCount}
filetype="image/*"
on:change={convertGradioUpload}
on:load={handle_upload}
bind:dragging
on:clear
on:select
parse_to_data_url={false}
>
<UploadText type="file" />
</Upload>
{/if}
</Block>
{/if}
</div>
<style lang="scss">
.image-upload {
height: var(--size-96);
:global(.block) {
height: inherit;
padding: 0;
border-radius: 0;
}
img {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
</style>

View File

@@ -1,22 +1,51 @@
<script>
<script lang="ts">
import { Button } from "@gradio/button";
import { createEventDispatcher } from "svelte";
export let showModal; // boolean
export let closeOnClick = true; // boolean
export const closeDialog = _ => doClose();
let dialog; // HTMLDialogElement
const dispatch = createEventDispatcher<{
close: undefined;
}>();
$: if (dialog && showModal) dialog.showModal();
function close(e: Event) {
if (!closeOnClick) {
e.preventDefault();
e.stopPropagation();
return false
}
doClose()
}
function doClose() {
dialog.close();
dispatch("close")
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<dialog
bind:this={dialog}
on:close={() => (showModal = false)}
on:click|self={() => dialog.close()}
on:close={close}
on:cancel={doClose}
on:click|self={close}
>
<div on:click|stopPropagation>
<slot name="header" />
<slot />
<div class="button-row">
<slot name="buttons">
<!-- svelte-ignore a11y-autofocus -->
<button autofocus on:click={() => dialog.close()}>Close</button>
<Button variant="secondary" on:click={doClose}>Close</Button>
</slot>
</div>
</div>
</dialog>
@@ -26,6 +55,7 @@
border-radius: 0.2em;
border: none;
padding: 0;
overflow: hidden;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.3);
@@ -58,4 +88,10 @@
button {
display: block;
}
.button-row {
display: flex;
flex-direction: row;
gap: var(--spacing-sm);
}
</style>

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { BlockTitle } from "@gradio/atoms";
import { createEventDispatcher } from "svelte";
export let value: number = 0;
export let min: number = -1024
export let max: number = 1024
export let step: number = 1;
export let label: string = "";
export let disabled: boolean = false;
let inputValue = value;
const dispatch = createEventDispatcher<{ change: number; release: number }>();
function handle_input(e: Event) {
const element = e.currentTarget as HTMLInputElement;
let newValue = parseFloat(element.value);
if (isNaN(newValue)) {
newValue = min;
}
inputValue = Math.min(Math.max(inputValue, min), max);
value = inputValue;
dispatch("release", value);
}
function handle_release(e: MouseEvent) {
dispatch("release", value);
}
$: {
inputValue = value;
dispatch("change", value);
}
const clamp = () => {
value = Math.min(Math.max(value, min), max);
dispatch("release", value);
};
</script>
<div class="wrap">
<div class="head">
<label>
<BlockTitle>{label}</BlockTitle>
</label>
<input
data-testid="number-input"
type="number"
bind:value={inputValue}
on:input={handle_input}
min={min}
max={max}
on:blur={clamp}
{step}
{disabled}
on:pointerup={handle_release}
/>
</div>
</div>
<style lang="scss">
.wrap {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.head {
display: flex;
> label {
padding: 0.5rem 1.0rem;
}
}
input[type="number"] {
display: block;
width: 100%;
position: relative;
outline: none !important;
box-shadow: var(--input-shadow);
border: var(--input-border-width) solid var(--input-border-color);
border-radius: var(--input-radius);
background: var(--input-background-fill);
padding: var(--size-2) var(--size-2);
color: var(--body-text-color);
font-size: var(--input-text-size);
line-height: var(--line-sm);
text-align: center;
}
input:disabled {
-webkit-text-fill-color: var(--body-text-color);
-webkit-opacity: 1;
opacity: 1;
}
input[type="number"]:focus {
box-shadow: var(--input-shadow-focus);
border-color: var(--input-border-color-focus);
}
input::placeholder {
color: var(--input-placeholder-color);
}
input[disabled] {
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { create_classes } from "@gradio/utils";
import type { Styles } from "@gradio/utils";
export let scale: number = 1;
export let min_width: number = 0;
export let elem_id: string = "";
export let elem_classes: Array<string> = [];
export let visible: boolean = true;
export let variant: "default" | "panel" | "compact" = "default";
export let style: Styles = {};
</script>
<div
id={elem_id}
class={elem_classes.join(" ")}
class:gap={style.gap !== false}
class:compact={variant === "compact"}
class:panel={variant === "panel"}
class:hide={!visible}
style={`min-width: min(${min_width}px, 100%); flex-grow: ${scale}`}
>
<slot />
</div>
<style>
div {
display: flex;
position: relative;
flex-direction: column;
}
div > :global(*),
div > :global(.form > *) {
width: var(--size-full);
}
.gap {
gap: var(--layout-gap);
}
.hide {
display: none;
}
.compact > :global(*),
.compact :global(.box) {
border-radius: 0;
}
.compact,
.panel {
border: solid var(--panel-border-width) var(--panel-border-color);
border-radius: var(--container-radius);
background: var(--panel-background-fill);
padding: var(--spacing-lg);
}
</style>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { Styles } from "@gradio/utils";
export let style: Styles = {};
export let elem_id: string;
export let elem_classes: Array<string> = [];
export let visible: boolean = true;
export let variant: "default" | "panel" | "compact" = "default";
export let min_width: string = "160px";
</script>
<div
class:compact={variant === "compact"}
class:panel={variant === "panel"}
class:unequal-height={style.equal_height === false}
class:stretch={style.equal_height}
class:hide={!visible}
id={elem_id}
class={elem_classes.join(" ")}
style="--row-min-width: {min_width}"
>
<slot />
</div>
<style>
div {
display: flex;
flex-wrap: wrap;
gap: var(--layout-gap);
width: var(--size-full);
}
.hide {
display: none;
}
.compact > :global(*),
.compact :global(.box) {
border-radius: 0;
}
.compact,
.panel {
border-radius: var(--container-radius);
background: var(--background-fill-secondary);
padding: var(--size-2);
}
.unequal-height {
align-items: flex-start;
}
.stretch {
align-items: stretch;
}
div > :global(*),
div > :global(.form > *) {
flex: 1 1 0%;
flex-wrap: wrap;
min-width: min(--row-min-width, 100%);
}
</style>

View File

@@ -5,10 +5,10 @@ import queueState from "$lib/stores/queueState";
import { BuiltInSlotType, LiteGraph, NodeMode, type ITextWidget, type IToggleWidget, type SerializedLGraphNode, type SlotLayout, type PropertyLayout } from "@litegraph-ts/core";
import { get } from "svelte/store";
import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode";
import type { ComfyWidgetNode, GalleryOutput, GalleryOutputEntry } from "./ComfyWidgetNodes";
import type { ComfyWidgetNode, ComfyExecutionResult, ComfyImageLocation } 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, type ComfyUploadImageAPIResponse } from "$lib/utils";
export class ComfyQueueEvents extends ComfyGraphNode {
static slotLayout: SlotLayout = {
@@ -63,7 +63,7 @@ LiteGraph.registerNodeType({
})
export interface ComfyStoreImagesActionProperties extends ComfyGraphNodeProperties {
images: GalleryOutput | null
images: ComfyExecutionResult | null
}
export class ComfyStoreImagesAction extends ComfyGraphNode {
@@ -90,7 +90,7 @@ export class ComfyStoreImagesAction extends ComfyGraphNode {
if (action !== "store" || !param || !("images" in param))
return;
this.setProperty("images", param as GalleryOutput)
this.setProperty("images", param as ComfyExecutionResult)
this.setOutputData(0, this.properties.images)
}
}
@@ -223,7 +223,7 @@ export class ComfyNotifyAction extends ComfyGraphNode {
// native notifications.
if (param != null && typeof param === "object") {
if ("images" in param) {
const output = param as GalleryOutput;
const output = param as ComfyExecutionResult;
const converted = convertComfyOutputToGradio(output);
if (converted.length > 0)
options.imageUrl = converted[0].data;
@@ -581,86 +581,6 @@ LiteGraph.registerNodeType({
type: "events/no_change"
})
export interface ComfyUploadImageActionProperties extends ComfyGraphNodeProperties {
folderType: "output" | "temp"
lastUploadedImageFile: string | null
}
export class ComfyUploadImageAction extends ComfyGraphNode {
override properties: ComfyUploadImageActionProperties = {
tags: [],
folderType: "output",
lastUploadedImageFile: null
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "filename", type: "string" },
{ name: "trigger", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "input_filename", type: "string" },
{ name: "uploaded", type: BuiltInSlotType.EVENT }
],
}
private _promise = null;
displayWidget: ITextWidget;
constructor(title?: string) {
super(title);
this.displayWidget = this.addWidget<ITextWidget>(
"text",
"File",
this.properties.lastUploadedImageFile,
"lastUploadedImageFile"
);
this.displayWidget.disabled = true;
}
override onExecute() {
this.setOutputData(0, this.properties.lastUploadedImageFile)
}
override onAction(action: any, param: any) {
if (action !== "trigger" || this._promise != null)
return;
const filename = this.getInputData(0)
if (typeof filename !== "string" || !filename) {
return;
}
const data: GalleryOutputEntry = {
filename,
subfolder: "",
type: this.properties.folderType || "output"
}
this._promise = uploadImageToComfyUI(data)
.then((json: ComfyUploadImageAPIResponse) => {
console.debug("[UploadImageAction] Succeeded", json)
this.properties.lastUploadedImageFile = json.name;
this.triggerSlot(1, this.properties.lastUploadedImageFile);
this._promise = null;
})
.catch((e) => {
console.error("Error uploading:", e)
notify(`Error uploading image to ComfyUi: ${e}`, { type: "error", timeout: 10000 })
this.properties.lastUploadedImageFile = null;
this._promise = null;
})
}
}
LiteGraph.registerNodeType({
class: ComfyUploadImageAction,
title: "Comfy.UploadImageAction",
desc: "Uploads an image from the specified ComfyUI folder into its input folder",
type: "actions/store_images"
})
export interface ComfySetPromptThumbnailsActionProperties extends ComfyGraphNodeProperties {
defaultFolderType: string | null
}
@@ -679,12 +599,12 @@ export class ComfySetPromptThumbnailsAction extends ComfyGraphNode {
_value: any = null;
override getPromptThumbnails(): GalleryOutputEntry[] | null {
override getPromptThumbnails(): ComfyImageLocation[] | null {
const data = this.getInputData(0)
const folderType = this.properties.folderType || "input";
const convertString = (s: string): GalleryOutputEntry => {
const convertString = (s: string): ComfyImageLocation => {
return { filename: data, subfolder: "", type: folderType }
}
@@ -693,13 +613,13 @@ export class ComfySetPromptThumbnailsAction extends ComfyGraphNode {
}
else if (data != null && typeof data === "object") {
if ("filename" in data && "type" in data)
return [data as GalleryOutputEntry];
return [data as ComfyImageLocation];
}
else if (Array.isArray(data) && data.length > 0) {
if (typeof data[0] === "string")
return data.map(convertString)
else if (typeof data[0] === "object" && "filename" in data[0] && "type" in data[0])
return data as GalleryOutputEntry[]
return data as ComfyImageLocation[]
}
return null;
}

View File

@@ -1,8 +1,8 @@
import LGraphCanvas from "@litegraph-ts/core/src/LGraphCanvas";
import ComfyGraphNode from "./ComfyGraphNode";
import ComfyWidgets from "$lib/widgets"
import type { ComfyWidgetNode, GalleryOutput } from "./ComfyWidgetNodes";
import { BuiltInSlotType, type SerializedLGraphNode } from "@litegraph-ts/core";
import type { ComfyWidgetNode, ComfyExecutionResult } from "./ComfyWidgetNodes";
import { BuiltInSlotShape, BuiltInSlotType, type SerializedLGraphNode } from "@litegraph-ts/core";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import type { ComfyInputConfig } from "$lib/IComfyInputSlot";
@@ -11,10 +11,12 @@ import type { ComfyInputConfig } from "$lib/IComfyInputSlot";
*/
export class ComfyBackendNode extends ComfyGraphNode {
comfyClass: string;
displayName: string | null;
constructor(title: string, comfyClass: string, nodeData: any) {
super(title)
this.type = comfyClass; // XXX: workaround dependency in LGraphNode.addInput()
this.displayName = nodeData.display_name;
this.comfyClass = comfyClass;
this.isBackendNode = true;
@@ -32,6 +34,7 @@ export class ComfyBackendNode extends ComfyGraphNode {
}
}
// comfy class -> input name -> input config
private static defaultInputConfigs: Record<string, Record<string, ComfyInputConfig>> = {}
private setup(nodeData: any) {
@@ -43,7 +46,7 @@ export class ComfyBackendNode extends ComfyGraphNode {
ComfyBackendNode.defaultInputConfigs[this.type] = {}
for (const inputName in inputs) {
const config = { minWidth: 1, minHeight: 1 };
const config: Partial<IComfyInputSlot> = {};
const inputData = inputs[inputName];
const type = inputData[0];
@@ -67,13 +70,14 @@ export class ComfyBackendNode extends ComfyGraphNode {
}
if ("widgetNodeType" in config)
ComfyBackendNode.defaultInputConfigs[this.type][config.name] = config.config
ComfyBackendNode.defaultInputConfigs[this.type][inputName] = (config as IComfyInputSlot).config
}
for (const o in nodeData["output"]) {
const output = nodeData["output"][o];
const outputName = nodeData["output_name"][o] || output;
this.addOutput(outputName, output);
const outputShape = nodeData["output_is_list"][o] ? BuiltInSlotShape.GRID_SHAPE : BuiltInSlotShape.CIRCLE_SHAPE;
this.addOutput(outputName, output, { shape: outputShape });
}
this.serialize_widgets = false;
@@ -110,7 +114,7 @@ export class ComfyBackendNode extends ComfyGraphNode {
}
}
override onExecuted(outputData: GalleryOutput) {
override onExecuted(outputData: ComfyExecutionResult) {
console.warn("onExecuted outputs", outputData)
this.triggerSlot(0, outputData)
}

View File

@@ -3,7 +3,7 @@ import type { SerializedPrompt } from "$lib/components/ComfyApp";
import type ComfyWidget from "$lib/components/widgets/ComfyWidget";
import { LGraph, LGraphNode, LLink, LiteGraph, NodeMode, type INodeInputSlot, type SerializedLGraphNode, type Vector2, type INodeOutputSlot, LConnectionKind, type SlotType, LGraphCanvas, getStaticPropertyOnInstance, type PropertyLayout, type SlotLayout } from "@litegraph-ts/core";
import type { SvelteComponentDev } from "svelte/internal";
import type { ComfyWidgetNode, GalleryOutput, GalleryOutputEntry } from "./ComfyWidgetNodes";
import type { ComfyWidgetNode, ComfyExecutionResult, ComfyImageLocation } from "./ComfyWidgetNodes";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import uiState from "$lib/stores/uiState";
import { get } from "svelte/store";
@@ -48,14 +48,14 @@ export default class ComfyGraphNode extends LGraphNode {
* Triggered when the backend sends a finished output back with this node's ID.
* Valid for output nodes like SaveImage and PreviewImage.
*/
onExecuted?(output: GalleryOutput): void;
onExecuted?(output: ComfyExecutionResult): void;
/*
* When a prompt is queued, this will be called on the node if it can
* provide any thumbnails for use with the prompt queue. Useful for HR Fix
* or img2img workloads.
*/
getPromptThumbnails?(): GalleryOutputEntry[] | null
getPromptThumbnails?(): ComfyImageLocation[] | null
/*
* Allows you to manually specify an auto-config for certain input slot

View File

@@ -1,241 +0,0 @@
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";
export interface ComfyImageCacheNodeProperties extends ComfyGraphNodeProperties {
images: GalleryOutput | null,
index: number,
filenames: Record<number, { filename: string | null, status: ImageCacheState }>,
genNumber: number,
updateMode: "replace" | "append"
}
type ImageCacheState = "none" | "uploading" | "failed" | "cached"
/*
* A node that can act as both an input and output image node by uploading
* the output file into ComfyUI's input folder.
*/
export default class ComfyImageCacheNode extends ComfyGraphNode {
override properties: ComfyImageCacheNodeProperties = {
tags: [],
images: null,
index: 0,
filenames: {},
genNumber: 0,
updateMode: "replace"
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "images", type: "OUTPUT" },
{ name: "index", type: "number" },
{ name: "store", type: BuiltInSlotType.ACTION, options: { color_off: "rebeccapurple", color_on: "rebeccapurple" } },
{ name: "clear", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "filename", type: "string" },
{ name: "state", type: "string" },
]
}
static propertyLayout: PropertyLayout = [
{ name: "updateMode", defaultValue: "replace", type: "enum", options: { values: ["replace", "append"] } }
]
override saveUserState = false;
private _uploadPromise: Promise<void> | null = null;
stateWidget: ITextWidget;
filenameWidget: ITextWidget;
modeWidget: IComboWidget;
constructor(name?: string) {
super(name)
this.stateWidget = this.addWidget<ITextWidget>(
"text",
"State",
"none"
);
this.stateWidget.disabled = true;
this.filenameWidget = this.addWidget<ITextWidget>(
"text",
"File",
""
);
this.filenameWidget.disabled = true;
this.modeWidget = this.addWidget<IComboWidget>(
"combo",
"Mode",
this.properties.updateMode,
null,
{ property: "updateMode", values: ["replace", "append"] }
);
}
override onPropertyChanged(property: string, value: any, prevValue?: any) {
if (property === "images") {
if (value != null)
this.properties.index = clamp(this.properties.index, 0, value.length)
else
this.properties.index = 0
}
else if (property === "updateMode") {
this.modeWidget.value = value;
}
this.updateWidgets()
}
private updateWidgets() {
if (this.properties.filenames && this.properties.images) {
const fileCount = this.properties.images.images.length;
const cachedCount = Object.keys(this.properties.filenames).length
console.warn(cachedCount, this.properties.filenames)
this.filenameWidget.value = `${fileCount} files, ${cachedCount} cached`
}
else {
this.filenameWidget.value = `No files cached`
}
}
override onExecute() {
const index = this.getInputData(1)
if (typeof index === "number")
this.setIndex(index)
const existing = this.properties.filenames[this.properties.index]
let state = "none"
if (existing)
state = existing.status
this.stateWidget.value = state
let filename = null
if (this.properties.index in this.properties.filenames)
filename = this.properties.filenames[this.properties.index].filename
this.setOutputData(0, filename)
this.setOutputData(1, state)
}
override stripUserState(o: SerializedLGraphNode) {
super.stripUserState(o);
o.properties.images = null
o.properties.index = 0
o.properties.filenames = {}
o.properties.genNumber = 0
}
private setIndex(newIndex: number, force: boolean = false) {
if (newIndex === this.properties.index && !force)
return;
if (!this.properties.images || newIndex < 0 || newIndex >= this.properties.images.images.length) {
return
}
this.setProperty("index", newIndex)
const data = this.properties.images.images[newIndex]
if (data == null) {
return;
}
this.properties.filenames ||= {}
const existing = this.properties.filenames[newIndex]
if (existing != null && existing.status === "cached") {
return
}
const lastGenNumber = this.properties.genNumber
// ComfyUI's LoadImage node only operates on files in its input
// folder. Usually we're dealing with an image in either the output
// folder (SaveImage) or the temp folder (PreviewImage). So we have
// to copy the image into ComfyUI's input folder first by using
// their upload API.
if (data.subfolder === "input") {
// Already in the correct folder for use by LoadImage
this.properties.filenames[newIndex] = { filename: data.filename, status: "cached" }
this.onPropertyChanged("filenames", this.properties.filenames)
}
else {
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)
if (lastGenNumber === this.properties.genNumber) {
this.properties.filenames[newIndex] = { filename: json.name, status: "cached" }
this.onPropertyChanged("filenames", this.properties.filenames)
}
else {
console.warn("[ComfyImageCacheNode] New generation since index switched!")
}
this._uploadPromise = null;
})
.catch((e) => {
console.error("Error uploading:", e)
if (lastGenNumber === this.properties.genNumber) {
this.properties.filenames[newIndex] = { filename: null, status: "failed" }
this.onPropertyChanged("filenames", this.properties.filenames)
}
else {
console.warn("[ComfyImageCacheNode] New generation since index switched!")
}
})
if (this._uploadPromise)
this._uploadPromise.then(() => promise)
else
this._uploadPromise = promise
}
}
override onAction(action: any, param: any) {
if (action === "clear") {
this.setProperty("images", null)
this.setProperty("filenames", {})
this.setProperty("index", 0)
this.updateWidgets();
return
}
if (param && "images" in param) {
this.setProperty("genNumber", this.properties.genNumber + 1)
const output = param as GalleryOutput;
if (this.properties.updateMode === "append" && this.properties.images != null) {
const newImages = this.properties.images.images.concat(output.images)
this.properties.images.images = newImages
this.setProperty("images", this.properties.images)
}
else {
this.setProperty("images", param as GalleryOutput)
this.setProperty("filenames", {})
}
console.debug("[ComfyImageCacheNode] Received output!", output, this.properties.updateMode, this.properties.images)
this.setIndex(0, true)
}
this.updateWidgets();
}
}
LiteGraph.registerNodeType({
class: ComfyImageCacheNode,
title: "Comfy.ImageCache",
desc: "Allows reusing a previously output image by uploading it into ComfyUI's input folder.",
type: "image/cache"
})

View File

@@ -0,0 +1,33 @@
import { LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import ComfyGraphNode from "./ComfyGraphNode";
import { comfyFileToAnnotatedFilepath, isComfyBoxImageMetadata, parseWhateverIntoImageMetadata } from "$lib/utils";
export default class ComfyImageToFilepathNode extends ComfyGraphNode {
static slotLayout: SlotLayout = {
inputs: [
{ name: "image", type: "COMFYBOX_IMAGES,COMFYBOX_IMAGE" },
],
outputs: [
{ name: "filepath", type: "string" },
]
}
override onExecute() {
const data = this.getInputData(0)
const meta = parseWhateverIntoImageMetadata(data);
if (meta == null || meta.length === 0) {
this.setOutputData(0, null)
return;
}
const path = comfyFileToAnnotatedFilepath(meta[0].comfyUIFile);
this.setOutputData(0, path);
}
}
LiteGraph.registerNodeType({
class: ComfyImageToFilepathNode,
title: "Comfy.ImageToFilepath",
desc: "Converts ComfyBox image metadata to an annotated filepath like \"image.png[output]\" for use with ComfyUI.",
type: "image/file_to_filepath"
})

View File

@@ -0,0 +1,35 @@
import { LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import ComfyGraphNode from "./ComfyGraphNode";
import { isComfyBoxImageMetadataArray } from "$lib/utils";
/*
* TODO: This is just a temporary workaround until litegraph can handle typed
* array arguments.
*/
export default class ComfyPickImageNode extends ComfyGraphNode {
static slotLayout: SlotLayout = {
inputs: [
{ name: "images", type: "COMFYBOX_IMAGES" },
],
outputs: [
{ name: "image", type: "COMFYBOX_IMAGE" },
]
}
override onExecute() {
const data = this.getInputData(0)
if (data == null || !isComfyBoxImageMetadataArray(data)) {
this.setOutputData(0, null)
return;
}
this.setOutputData(0, data[0]);
}
}
LiteGraph.registerNodeType({
class: ComfyPickImageNode,
title: "Comfy.PickImage",
desc: "Picks out the first image from an array of ComfyBox images.",
type: "image/pick_image"
})

View File

@@ -1,10 +1,10 @@
import { LiteGraph, type ContextMenuItem, type LGraphNode, type Vector2, LConnectionKind, LLink, LGraphCanvas, type SlotType, TitleMode, type SlotLayout, LGraph, type INodeInputSlot, type ITextWidget, type INodeOutputSlot, type SerializedLGraphNode, BuiltInSlotType, type PropertyLayout, type IComboWidget, NodeMode, type INumberWidget } from "@litegraph-ts/core";
import { LiteGraph, type ContextMenuItem, type LGraphNode, type Vector2, LConnectionKind, LLink, LGraphCanvas, type SlotType, TitleMode, type SlotLayout, LGraph, type INodeInputSlot, type ITextWidget, type INodeOutputSlot, type SerializedLGraphNode, BuiltInSlotType, type PropertyLayout, type IComboWidget, NodeMode, type INumberWidget, type UUID } from "@litegraph-ts/core";
import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode";
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, isComfyBoxImageMetadata, filenameToComfyBoxMetadata, type ComfyBoxImageMetadata, isComfyExecutionResult, executionResultToImageMetadata, parseWhateverIntoImageMetadata } from "$lib/utils"
import layoutState from "$lib/stores/layoutState";
import type { FileData as GradioFileData } from "@gradio/upload";
import queueState from "$lib/stores/queueState";
@@ -17,7 +17,8 @@ import ButtonWidget from "$lib/widgets/ButtonWidget.svelte";
import CheckboxWidget from "$lib/widgets/CheckboxWidget.svelte";
import RadioWidget from "$lib/widgets/RadioWidget.svelte";
import ImageUploadWidget from "$lib/widgets/ImageUploadWidget.svelte";
import ImageCompareWidget from "$lib/widgets/ImageCompareWidget.svelte";
import ImageEditorWidget from "$lib/widgets/ImageEditorWidget.svelte";
import type { NodeID } from "$lib/api";
export type AutoConfigOptions = {
includeProperties?: Set<string> | null,
@@ -33,7 +34,7 @@ export type AutoConfigOptions = {
* - Go to layoutState, look for `ALL_ATTRIBUTES,` insert or find a "variant"
* attribute and set `validNodeTypes` to the type of the litegraph node
* - Add a new entry in the `values` array, like "knob" or "dial" for ComfySliderWidget
* - Add an {#if widget.attrs.variant === <...>} statement in the corresponding Svelte component
* - Add an {#if widget.attrs.variant === <...>} statement in the existing Svelte component
*
* Also, BEWARE of calling setOutputData() and triggerSlot() on the same frame!
* You will have to either implement an internal delay on the event triggering
@@ -91,7 +92,7 @@ export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
// TODO these are bad, create override methods instead
// input slots
inputIndex: number = 0;
inputIndex: number | null = null;
storeActionName: string | null = "store";
// output slots
@@ -169,6 +170,10 @@ export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
parseValue(value: any): T { return value as T };
getValue(): T {
return get(this.value);
}
setValue(value: any, noChangedEvent: boolean = false) {
if (noChangedEvent)
this._noChangedEvent = true;
@@ -193,7 +198,7 @@ export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
* Logic to run if this widget can be treated as output (slider, combo, text)
*/
override onExecute(param: any, options: object) {
if (this.copyFromInputLink) {
if (this.inputIndex != null) {
if (this.inputs.length >= this.inputIndex) {
const data = this.getInputData(this.inputIndex)
if (data != null) { // TODO can "null" be a legitimate value here?
@@ -201,9 +206,11 @@ export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
}
}
}
if (this.outputIndex != null) {
if (this.outputs.length >= this.outputIndex) {
this.setOutputData(this.outputIndex, get(this.value))
}
}
for (const propName in this.shownOutputProperties) {
const data = this.shownOutputProperties[propName]
this.setOutputData(data.index, this.properties[propName])
@@ -265,7 +272,7 @@ export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
}
if (options.setWidgetTitle) {
const widget = layoutState.findLayoutForNode(this.id)
const widget = layoutState.findLayoutForNode(this.id as NodeID)
if (widget && input.name !== "") {
widget.attrs.title = input.name;
}
@@ -284,7 +291,7 @@ export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
}
notifyPropsChanged() {
const layoutEntry = layoutState.findLayoutEntryForNode(this.id)
const layoutEntry = layoutState.findLayoutEntryForNode(this.id as NodeID)
if (layoutEntry && layoutEntry.parent) {
layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1)
}
@@ -596,15 +603,21 @@ LiteGraph.registerNodeType({
})
/** Raw output as received from ComfyUI's backend */
export type GalleryOutput = {
images: GalleryOutputEntry[]
export interface ComfyExecutionResult {
// Technically this response can contain arbitrary data, but "images" is the
// most frequently used as it's output by LoadImage and PreviewImage, the
// only two output nodes in base ComfyUI.
images: ComfyImageLocation[] | null,
}
/** Raw output entry as received from ComfyUI's backend */
export type GalleryOutputEntry = {
export type ComfyImageLocation = {
/* Filename with extension in the subfolder. */
filename: string,
/* Subfolder in the containing folder. */
subfolder: string,
type: string
/* Base ComfyUI folder where the image is located. */
type: ComfyUploadImageType
}
export interface ComfyGalleryProperties extends ComfyWidgetProperties {
@@ -612,7 +625,7 @@ export interface ComfyGalleryProperties extends ComfyWidgetProperties {
updateMode: "replace" | "append",
}
export class ComfyGalleryNode extends ComfyWidgetNode<GradioFileData[]> {
export class ComfyGalleryNode extends ComfyWidgetNode<ComfyBoxImageMetadata[]> {
override properties: ComfyGalleryProperties = {
tags: [],
defaultValue: [],
@@ -623,14 +636,11 @@ export class ComfyGalleryNode extends ComfyWidgetNode<GradioFileData[]> {
static slotLayout: SlotLayout = {
inputs: [
{ name: "images", type: "OUTPUT" },
{ name: "store", type: BuiltInSlotType.ACTION, options: { color_off: "rebeccapurple", color_on: "rebeccapurple" } },
{ name: "clear", type: BuiltInSlotType.ACTION }
{ name: "store", type: BuiltInSlotType.ACTION, options: { color_off: "rebeccapurple", color_on: "rebeccapurple" } }
],
outputs: [
{ name: "images", type: "COMFYBOX_IMAGES" },
{ name: "selected_index", type: "number" },
{ name: "width", type: "number" },
{ name: "height", type: "number" },
{ name: "filename", type: "string" },
]
}
@@ -640,7 +650,7 @@ export class ComfyGalleryNode extends ComfyWidgetNode<GradioFileData[]> {
override svelteComponentType = GalleryWidget
override defaultValue = []
override copyFromInputLink = false;
override inputIndex = null;
override saveUserState = false;
override outputIndex = null;
override changedIndex = null;
@@ -663,53 +673,30 @@ export class ComfyGalleryNode extends ComfyWidgetNode<GradioFileData[]> {
}
}
imageSize: Vector2 = [1, 1]
override onExecute() {
const index = this.properties.index;
this.setOutputData(0, index)
this.setOutputData(1, this.imageSize[0])
this.setOutputData(2, this.imageSize[1])
let filename: string | null = null;
if (index != null) {
const entry = get(this.value)[index];
if (entry)
filename = entry.name
}
this.setOutputData(3, filename)
this.setOutputData(0, get(this.value))
this.setOutputData(1, this.properties.index)
}
override onAction(action: any, param: any, options: { action_call?: string }) {
super.onAction(action, param, options)
if (action === "clear") {
this.setValue([])
}
}
override formatValue(value: GradioFileData[] | null): string {
override formatValue(value: ComfyBoxImageMetadata[] | null): string {
return `Images: ${value?.length || 0}`
}
override parseValue(param: any): GradioFileData[] {
if (!(typeof param === "object" && "images" in param)) {
return []
}
override parseValue(param: any): ComfyBoxImageMetadata[] {
const meta = parseWhateverIntoImageMetadata(param) || [];
const data = param as GalleryOutput
console.debug("[ComfyGalleryNode] Received output!", data)
const galleryItems: GradioFileData[] = convertComfyOutputToGradio(data)
console.debug("[ComfyGalleryNode] Received output!", param)
if (this.properties.updateMode === "append") {
const currentValue = get(this.value)
return currentValue.concat(galleryItems)
return currentValue.concat(meta)
}
else {
return galleryItems;
return meta;
}
}
@@ -886,15 +873,13 @@ LiteGraph.registerNodeType({
type: "ui/radio"
})
export interface ComfyImageUploadProperties extends ComfyWidgetProperties {
fileCount: "single" | "multiple" // gradio File component format
export interface ComfyImageEditorNodeProperties extends ComfyWidgetProperties {
}
export class ComfyImageUploadNode extends ComfyWidgetNode<Array<GradioFileData>> {
override properties: ComfyImageUploadProperties = {
export class ComfyImageEditorNode extends ComfyWidgetNode<ComfyBoxImageMetadata[]> {
override properties: ComfyImageEditorNodeProperties = {
defaultValue: [],
tags: [],
fileCount: "single",
}
static slotLayout: SlotLayout = {
@@ -902,55 +887,25 @@ export class ComfyImageUploadNode extends ComfyWidgetNode<Array<GradioFileData>>
{ name: "store", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "filename", type: "string" }, // TODO support batches
{ name: "width", type: "number" },
{ name: "height", type: "number" },
{ name: "image_count", type: "number" },
{ name: "images", type: "COMFYBOX_IMAGES" }, // TODO support batches
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override svelteComponentType = ImageUploadWidget;
override svelteComponentType = ImageEditorWidget;
override defaultValue = [];
override outputIndex = null;
override changedIndex = 3;
override outputIndex = 0;
override inputIndex = null;
override changedIndex = 1;
override storeActionName = "store";
override saveUserState = false;
imageSize: Vector2 = [1, 1];
constructor(name?: string) {
super(name, [])
}
override parseValue(value: any): GradioFileData[] {
if (value == null)
return []
if (typeof value === "string" && value !== "") { // Single filename
return [{ name: value, data: value, orig_name: value, is_file: true }]
}
else {
return []
}
}
override onExecute(param: any, options: object) {
super.onExecute(param, options);
const value = get(this.value)
if (value.length > 0 && value[0].name) {
this.setOutputData(0, value[0].name) // TODO when ComfyUI LoadImage supports loading an image batch
this.setOutputData(1, this.imageSize[0])
this.setOutputData(2, this.imageSize[1])
this.setOutputData(3, value.length)
}
else {
this.setOutputData(0, "")
this.setOutputData(1, 1)
this.setOutputData(2, 1)
this.setOutputData(3, 0)
}
override parseValue(value: any): ComfyBoxImageMetadata[] {
return parseWhateverIntoImageMetadata(value) || [];
}
override formatValue(value: GradioFileData[]): string {
@@ -959,97 +914,8 @@ export class ComfyImageUploadNode extends ComfyWidgetNode<Array<GradioFileData>>
}
LiteGraph.registerNodeType({
class: ComfyImageUploadNode,
title: "UI.ImageUpload",
desc: "Widget that lets you upload images into ComfyUI's input folder",
type: "ui/image_upload"
})
export interface ComfyImageCompareNodeProperties extends ComfyWidgetProperties {
}
export type FileNameOrGalleryData = string | GalleryOutputEntry;
export type ImageCompareData = [FileNameOrGalleryData, FileNameOrGalleryData]
export class ComfyImageCompareNode extends ComfyWidgetNode<ImageCompareData> {
override properties: ComfyImageCompareNodeProperties = {
defaultValue: [],
tags: [],
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "store", type: BuiltInSlotType.ACTION },
{ name: "left_image", type: "string" },
{ name: "right_image", type: "string" },
],
outputs: [
]
}
override svelteComponentType = ImageCompareWidget;
override defaultValue: ImageCompareData = ["", ""];
override outputIndex = null;
override changedIndex = 3;
override storeActionName = "store";
override saveUserState = false;
constructor(name?: string) {
super(name, ["", ""])
}
override onExecute() {
const valueA = this.getInputData(1)
const valueB = this.getInputData(2)
let current = get(this.value)
let changed = false;
if (valueA != null && current[0] != valueA) {
current[0] = valueA
changed = true;
}
if (valueB != null && current[1] != valueB) {
current[1] = valueB
changed = true;
}
if (changed)
this.value.set(current)
}
override parseValue(value: any): ImageCompareData {
if (value == null) {
return ["", ""]
}
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 ImageCompareData
}
else if (typeof value === "object" && "images" in value && value.images.length > 0) {
const output = value as GalleryOutput
const prevValue = get(this.value)
prevValue.push(output.images[0].filename)
if (prevValue.length > 2)
prevValue.splice(0, 1)
return prevValue as ImageCompareData
}
else if (Array.isArray(value) && typeof value[0] === "string" && typeof value[1] === "string") {
return value as ImageCompareData
}
else {
return ["", ""]
}
}
override formatValue(value: GradioFileData[]): string {
return `Images: ${value?.length || 0}`
}
}
LiteGraph.registerNodeType({
class: ComfyImageCompareNode,
title: "UI.ImageCompare",
desc: "Widget that lets you compare two images",
type: "ui/image_compare"
class: ComfyImageEditorNode,
title: "UI.ImageEditor",
desc: "Widget that lets you upload and edit a multi-layered image. Can also act like a standalone image uploader.",
type: "ui/image_editor"
})

View File

@@ -14,6 +14,7 @@ export {
export { default as ComfyPickFirstNode } from "./ComfyPickFirstNode"
export { default as ComfyValueControl } from "./ComfyValueControl"
export { default as ComfySelector } from "./ComfySelector"
export { default as ComfyImageCacheNode } from "./ComfyImageCacheNode"
export { default as ComfyTriggerNewEventNode } from "./ComfyTriggerNewEventNode"
export { default as ComfyConfigureQueuePromptButton } from "./ComfyConfigureQueuePromptButton"
export { default as ComfyPickImageNode } from "./ComfyPickImageNode"
export { default as ComfyImageToFilepathNode } from "./ComfyImageToFilepathNode"

View File

@@ -5,6 +5,7 @@ import { type LGraphNode, type IWidget, type LGraph, NodeMode, type LGraphRemove
import { SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { ComfyWidgetNode } from '$lib/nodes';
import type { NodeID } from '$lib/api';
import { v4 as uuidv4 } from "uuid";
type DragItemEntry = {
/*
@@ -61,11 +62,6 @@ export type LayoutState = {
*/
allItemsByNode: Record<NodeID, DragItemEntry>,
/*
* Next ID to use for instantiating a new drag item
*/
currentId: UUID,
/*
* Selected drag items.
*/
@@ -411,6 +407,18 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: ""
},
// Editor
{
name: "variant",
type: "enum",
location: "widget",
editable: true,
validNodeTypes: ["ui/image_editor"],
values: ["inlineEditor", "fileUpload"],
defaultValue: "inlineEditor",
refreshPanelOnChange: true
},
// Gallery
{
name: "variant",
@@ -668,7 +676,6 @@ const store: Writable<LayoutState> = writable({
root: null,
allItems: {},
allItemsByNode: {},
currentId: 0,
currentSelection: [],
currentSelectionNodes: [],
isMenuOpen: false,
@@ -703,7 +710,7 @@ function addContainer(parent: ContainerLayout | null, attrs: Partial<Attributes>
const state = get(store);
const dragItem: ContainerLayout = {
type: "container",
id: `${state.currentId++}`,
id: uuidv4(),
attrsChanged: writable(0),
attrs: {
...defaultWidgetAttributes,
@@ -726,7 +733,7 @@ function addWidget(parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partia
const widgetName = "Widget"
const dragItem: WidgetLayout = {
type: "widget",
id: `${state.currentId++}`,
id: uuidv4(),
node: node,
attrsChanged: writable(0),
attrs: {
@@ -939,7 +946,6 @@ function initDefaultLayout() {
root: null,
allItems: {},
allItemsByNode: {},
currentId: 0,
currentSelection: [],
currentSelectionNodes: [],
isMenuOpen: false,
@@ -964,7 +970,6 @@ function initDefaultLayout() {
export type SerializedLayoutState = {
root: DragItemID | null,
allItems: Record<DragItemID, SerializedDragEntry>,
currentId: UUID,
attrs: LayoutAttributes
}
@@ -1002,7 +1007,6 @@ function serialize(): SerializedLayoutState {
return {
root: state.root?.id,
allItems,
currentId: state.currentId,
attrs: state.attrs
}
}
@@ -1055,7 +1059,6 @@ function deserialize(data: SerializedLayoutState, graph: LGraph) {
root,
allItems,
allItemsByNode,
currentId: data.currentId,
currentSelection: [],
currentSelectionNodes: [],
isMenuOpen: false,

View File

@@ -1,6 +1,6 @@
import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, NodeID, PromptID } from "$lib/api";
import type { Progress, SerializedPromptInputsAll, SerializedPromptOutputs } from "$lib/components/ComfyApp";
import type { GalleryOutput } from "$lib/nodes/ComfyWidgetNodes";
import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes";
import notify from "$lib/notify";
import { get, writable, type Writable } from "svelte/store";
@@ -19,7 +19,7 @@ type QueueStateOps = {
executionError: (promptID: PromptID, message: string) => void,
progressUpdated: (progress: Progress) => void
afterQueued: (promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void
onExecuted: (promptID: PromptID, nodeID: NodeID, output: GalleryOutput) => void
onExecuted: (promptID: PromptID, nodeID: NodeID, output: ComfyExecutionResult) => void
}
export type QueueEntry = {
@@ -257,7 +257,7 @@ function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromp
})
}
function onExecuted(promptID: PromptID, nodeID: NodeID, output: GalleryOutput) {
function onExecuted(promptID: PromptID, nodeID: NodeID, output: ComfyExecutionResult) {
console.debug("[queueState] onExecuted", promptID, nodeID, output)
store.update(s => {
const [index, entry, queue] = findEntryInPending(promptID)

View File

@@ -6,7 +6,7 @@ import { get } from "svelte/store"
import layoutState from "$lib/stores/layoutState"
import type { SvelteComponentDev } from "svelte/internal";
import type { SerializedLGraph } from "@litegraph-ts/core";
import type { GalleryOutput, GalleryOutputEntry } from "./nodes/ComfyWidgetNodes";
import type { FileNameOrGalleryData, ComfyExecutionResult, ComfyImageLocation } from "./nodes/ComfyWidgetNodes";
import type { FileData as GradioFileData } from "@gradio/upload";
export function clamp(n: number, min: number, max: number): number {
@@ -125,8 +125,11 @@ export const debounce = (callback: Function, wait = 250) => {
};
};
export function convertComfyOutputToGradio(output: GalleryOutput): GradioFileData[] {
return output.images.map(r => {
export function convertComfyOutputToGradio(output: ComfyExecutionResult): GradioFileData[] {
return output.images.map(convertComfyOutputEntryToGradio);
}
export function convertComfyOutputEntryToGradio(r: ComfyImageLocation): GradioFileData {
const url = `http://${location.hostname}:8188` // TODO make configurable
const params = new URLSearchParams(r)
const fileData: GradioFileData = {
@@ -136,15 +139,34 @@ export function convertComfyOutputToGradio(output: GalleryOutput): GradioFileDat
data: url + "/view?" + params
}
return fileData
});
}
export function convertComfyOutputToComfyURL(output: GalleryOutputEntry): string {
export function convertComfyOutputToComfyURL(output: FileNameOrGalleryData): string {
if (typeof output === "string")
return output;
const params = new URLSearchParams(output)
const url = `http://${location.hostname}:8188` // TODO make configurable
return url + "/view?" + params
}
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: ComfyUploadImageType = "input"): ComfyImageLocation {
if (!fileData.is_file)
throw "Can't convert blob data to comfy output!"
return {
filename: fileData.name,
subfolder: "",
type
}
}
export function convertFilenameToComfyURL(filename: string,
subfolder: string = "",
type: "input" | "output" | "temp" = "output"): string {
@@ -171,26 +193,137 @@ 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<ComfyUploadImageAPIResponse> {
const url = `http://${location.hostname}:8188` // TODO make configurable
const params = new URLSearchParams(data)
/*
* Uploads an image into ComfyUI's `input` folder.
*/
export async function uploadImageToComfyUI(blob: Blob, filename: string, type: ComfyUploadImageType, subfolder: string = "", overwrite: boolean = false): Promise<ComfyImageLocation> {
console.debug("[utils] Uploading image to ComfyUI", filename, blob.size)
const url = `http://${location.hostname}:8188` // TODO make configurable
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", {
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
}
});
}
/*
* Convenient type for passing around image filepaths and their metadata with
* wires. Needs to be converted to a filename for use with LoadImage.
*
* Litegraph type is COMFYBOX_IMAGE. The array type is COMFYBOX_IMAGES.
*/
export type ComfyBoxImageMetadata = {
/* For easy structural type detection */
isComfyBoxImageMetadata: true,
/* Pointer to where this image resides in ComfyUI. */
comfyUIFile: ComfyImageLocation,
/* Readable name of the image. */
name: string
/* Tags applicable to this image, like ["mask"]. */
tags: string[],
/* Image width. */
width?: number,
/* Image height. */
height?: number,
}
export function isComfyBoxImageMetadata(value: any): value is ComfyBoxImageMetadata {
return value && typeof value === "object" && (value as any).isComfyBoxImageMetadata;
}
export function isComfyBoxImageMetadataArray(value: any): value is ComfyBoxImageMetadata[] {
return Array.isArray(value) && value.every(isComfyBoxImageMetadata);
}
export function isComfyExecutionResult(value: any): value is ComfyExecutionResult {
return value && typeof value === "object" && Array.isArray(value.images)
}
export function filenameToComfyBoxMetadata(filename: string, type: ComfyUploadImageType, subfolder: string = ""): ComfyBoxImageMetadata {
return {
isComfyBoxImageMetadata: true,
comfyUIFile: {
filename,
subfolder,
type
},
name: "Filename",
tags: [],
}
}
export function comfyFileToComfyBoxMetadata(comfyUIFile: ComfyImageLocation): ComfyBoxImageMetadata {
return {
isComfyBoxImageMetadata: true,
comfyUIFile,
name: "File",
tags: [],
}
}
/*
* Converts a ComfyUI file into an annotated filepath. Backend nodes like
* LoadImage support syntax like "subfolder/image.png [output]" to specify which
* image folder to load from.
*/
export function comfyFileToAnnotatedFilepath(comfyUIFile: ComfyImageLocation): string {
let path = ""
if (comfyUIFile.subfolder != "")
path = comfyUIFile.subfolder + "/";
path += `${comfyUIFile.filename} [${comfyUIFile.type}]`
return path;
}
export function executionResultToImageMetadata(result: ComfyExecutionResult): ComfyBoxImageMetadata[] {
return result.images.map(comfyFileToComfyBoxMetadata)
}
export function parseWhateverIntoImageMetadata(param: any): ComfyBoxImageMetadata[] | null {
let meta: ComfyBoxImageMetadata[] | null = null
if (isComfyBoxImageMetadata(param)) {
meta = [param];
}
else if (Array.isArray(param) && param.every(isComfyBoxImageMetadata)) {
meta = param
}
else if (isComfyExecutionResult(param)) {
meta = executionResultToImageMetadata(param)
}
return meta;
}
export function comfyBoxImageToComfyFile(image: ComfyBoxImageMetadata): ComfyImageLocation {
return image.comfyUIFile
}
export function comfyBoxImageToComfyURL(image: ComfyBoxImageMetadata): string {
return convertComfyOutputToComfyURL(image.comfyUIFile)
}

View File

@@ -10,13 +10,13 @@
import type { ComfyGalleryNode } from "$lib/nodes/ComfyWidgetNodes";
import type { FileData as GradioFileData } from "@gradio/upload";
import type { SelectData as GradioSelectData } from "@gradio/utils";
import { clamp } from "$lib/utils";
import { clamp, comfyBoxImageToComfyURL, type ComfyBoxImageMetadata } from "$lib/utils";
import { f7 } from "framework7-svelte";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyGalleryNode | null = null;
let nodeValue: Writable<GradioFileData[]> | null = null;
let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null;
let propsChanged: Writable<number> | null = null;
let option: number | null = null;
let imageWidth: number = 1;
@@ -154,8 +154,10 @@
<div class="wrapper comfy-image-widget" style={widget.attrs.style || ""} bind:this={element}>
<Block variant="solid" padding={false}>
{#if $nodeValue && $nodeValue.length > 0}
{@const value = $nodeValue[$nodeValue.length-1]}
{@const url = comfyBoxImageToComfyURL(value)}
<StaticImage
value={$nodeValue[$nodeValue.length-1].data}
value={url}
show_label={widget.attrs.title != ""}
label={widget.attrs.title}
bind:imageWidth
@@ -167,11 +169,12 @@
</Block>
</div>
{:else}
{@const images = $nodeValue.map(comfyBoxImageToComfyURL)}
<div class="wrapper comfy-gallery-widget gradio-gallery" style={widget.attrs.style || ""} bind:this={element}>
<Block variant="solid" padding={false}>
<div class="padding">
<Gallery
bind:value={$nodeValue}
value={images}
label={widget.attrs.title}
show_label={widget.attrs.title !== ""}
{style}

View File

@@ -1,80 +0,0 @@
<script lang="ts">
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Block } from "@gradio/atoms";
import ImageComparison from "$lib/components/ImageComparison.svelte";
import { get, type Writable, writable } from "svelte/store";
import { isDisabled } from "./utils"
import type { SelectData } from "@gradio/utils";
import type { ComfyImageCompareNode, ImageCompareData } from "$lib/nodes/ComfyWidgetNodes";
import { convertFilenameToComfyURL } from "$lib/utils";
import { TabItem, Tabs } from "@gradio/tabs";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyImageCompareNode | null = null;
let nodeValue: Writable<ImageCompareData> | null = null;
let attrsChanged: Writable<number> | null = null;
let leftUrl: string = ""
let rightUrl: string = ""
$: widget && setNodeValue(widget);
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfyImageCompareNode
nodeValue = node.value;
attrsChanged = widget.attrsChanged;
}
};
const urlPattern = /^((http|https|ftp):\/\/)/;
$: updateUrls($nodeValue);
function updateUrls(value: ImageCompareData) {
leftUrl = ""
rightUrl = ""
console.warn("UPD", value)
if (typeof value[0] === "string") {
if (urlPattern.test(value[0]))
leftUrl = value[0]
else
leftUrl = convertFilenameToComfyURL(value[0])
}
if (typeof value[1] === "string") {
if (urlPattern.test(value[1]))
rightUrl = value[1]
else
rightUrl = convertFilenameToComfyURL(value[1])
}
}
</script>
<div class="wrapper comfy-compare-widget">
<Block>
<Tabs elem_classes={["gradio-tabs"]}>
<TabItem name="Slider">
<ImageComparison>
{#if leftUrl && leftUrl != ""}
{@const props = { slot: "first" }}
<img {...props} alt="Left" src={leftUrl} />
{/if}
{#if rightUrl && leftUrl != ""}
{@const props = { slot: "second" }}
<img {...props} alt="Right" src={rightUrl} />
{/if}
</ImageComparison>
</TabItem>
</Tabs>
</Block>
</div>
<style lang="scss">
.comfy-compare-widget {
max-width: 40rem;
img {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,343 @@
<script lang="ts">
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Block } from "@gradio/atoms";
import { TextBox } from "@gradio/form";
import Row from "$lib/components/gradio/app/Row.svelte";
import { get, type Writable } from "svelte/store";
import Modal from "$lib/components/Modal.svelte";
import { Button } from "@gradio/button";
import type { ComfyImageEditorNode, ComfyImageLocation } from "$lib/nodes/ComfyWidgetNodes";
import { Embed as Klecks } from "klecks";
import "klecks/style/style.scss";
import ImageUpload from "$lib/components/ImageUpload.svelte";
import { uploadImageToComfyUI, type ComfyBoxImageMetadata, comfyFileToComfyBoxMetadata, comfyBoxImageToComfyURL, comfyBoxImageToComfyFile, type ComfyUploadImageType } from "$lib/utils";
import notify from "$lib/notify";
import NumberInput from "$lib/components/NumberInput.svelte";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyImageEditorNode | null = null;
let nodeValue: Writable<ComfyBoxImageMetadata[]> | null = null;
let attrsChanged: Writable<number> | null = null;
let imgWidth: number = 0;
let imgHeight: number = 0;
$: widget && setNodeValue(widget);
$: if ($nodeValue && $nodeValue.length > 0) {
// TODO improve
if (imgWidth > 0 && imgHeight > 0) {
$nodeValue[0].width = imgWidth
$nodeValue[0].height = imgHeight
}
else {
$nodeValue[0].width = 0
$nodeValue[0].height = 0
}
}
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfyImageEditorNode
nodeValue = node.value;
attrsChanged = widget.attrsChanged;
status = $nodeValue && $nodeValue.length > 0 ? "uploaded" : "empty"
}
};
let editorRoot: HTMLDivElement | null = null;
let showModal = false;
let kl: Klecks | null = null;
function disposeEditor() {
console.warn("[ImageEditorWidget] CLOSING", widget, $nodeValue)
if (editorRoot) {
while (editorRoot.firstChild) {
editorRoot.removeChild(editorRoot.firstChild);
}
}
kl = null;
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<HTMLImageElement> {
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";
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 = `http://${location.hostname}:8188` // TODO make configurable
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);
}
let status = "empty";
let uploadError = null;
function onUploading() {
console.warn("UPLOADING!!!")
uploadError = null;
status = "uploading"
}
function onUploaded(e: CustomEvent<ComfyImageLocation[]>) {
console.warn("UPLOADED!!!")
uploadError = null;
status = "uploaded"
$nodeValue = e.detail.map(comfyFileToComfyBoxMetadata);
}
function onClear() {
console.warn("CLEAR!!!")
uploadError = null;
status = "empty"
$nodeValue = []
}
function onUploadError(e: CustomEvent<any>) {
console.warn("ERROR!!!")
status = "error"
uploadError = e.detail
$nodeValue = []
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)
else
_value = []
$: canEdit = status === "empty" || status === "uploaded";
</script>
<div class="wrapper comfy-image-editor">
{#if widget.attrs.variant === "fileUpload" || isMobile}
<ImageUpload value={_value}
bind:imgWidth
bind: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={openImageEditor}
/>
{:else}
<div class="comfy-image-editor-panel">
<ImageUpload value={_value}
bind:imgWidth
bind: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={openImageEditor}
/>
<Modal bind:showModal closeOnClick={false} on:close={disposeEditor} bind:closeDialog>
<div>
<div id="klecks-loading-screen">
<span id="klecks-loading-screen-text"></span>
</div>
<div class="image-editor-root" bind:this={editorRoot} />
</div>
<div slot="buttons">
<Button variant="primary" on:click={saveAndClose}>
Save and Close
</Button>
<Button variant="secondary" on:click={closeDialog}>
Discard Edits
</Button>
</div>
</Modal>
<Block>
{#if !$nodeValue || $nodeValue.length === 0}
<Row>
<Row>
<Button variant="secondary" disabled={!canEdit} on:click={openImageEditor}>
Create Image
</Button>
<div>
<TextBox show_label={false} disabled={true} value="Status: {status}"/>
</div>
{#if uploadError}
<div>
Upload error: {uploadError}
</div>
{/if}
</Row>
<Row>
<NumberInput label={"Width"} min={64} max={2048} step={64} bind:value={blankImageWidth} />
<NumberInput label={"Height"} min={64} max={2048} step={64} bind:value={blankImageHeight} />
</Row>
</Row>
{:else}
<Row>
<Button variant="secondary" disabled={!canEdit} on:click={openImageEditor}>
Edit Image
</Button>
<div>
<TextBox show_label={false} disabled={true} value="Status: {status}"/>
</div>
{#if uploadError}
<div>
Upload error: {uploadError}
</div>
{/if}
</Row>
{/if}
</Block>
</div>
{/if}
</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;
}
</style>

View File

@@ -1,247 +0,0 @@
<script lang="ts">
import { Block, BlockLabel, Empty } from "@gradio/atoms";
import { File as FileIcon } from "@gradio/icons";
import { ModifyUpload, Upload, blobToBase64, normalise_file } from "@gradio/upload";
import type { WidgetLayout } from "$lib/stores/layoutState";
import type { Writable } from "svelte/store";
import type { ComfyGalleryNode, ComfyImageUploadNode, GalleryOutputEntry } from "$lib/nodes/ComfyWidgetNodes";
import type { FileData as GradioFileData } from "@gradio/upload";
import UploadText from "$lib/components/gradio/app/UploadText.svelte";
import { tick } from "svelte";
import notify from "$lib/notify";
import type { Vector2 } from "@litegraph-ts/core";
export let widget: WidgetLayout | null = null;
export let isMobile: boolean = false;
let node: ComfyImageUploadNode | null = null;
let nodeValue: Writable<Array<GradioFileData>> | null = null;
let propsChanged: Writable<number> | null = null;
let dragging = false;
let pending_upload = false;
let old_value: Array<GradioFileData> | null = null;
let imgElem: HTMLImageElement | null = null
let imgWidth: number = 1;
let imgHeight: number = 1;
$: widget && setNodeValue(widget);
let _value: GradioFileData[] | null = null;
const root = "comf"
const root_url = "https//ComfyUI!"
$: _value = normalise_file($nodeValue, root, root_url);
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfyImageUploadNode
nodeValue = node.value;
propsChanged = node.propsChanged;
}
};
$: if (!(node && _value && _value.length > 0 && imgElem)) {
imgWidth = 1
imgHeight = 1
node.imageSize = [1, 1]
}
else if (imgWidth > 1 || imgHeight > 1) {
node.imageSize = [imgWidth, imgHeight]
}
else {
node.imageSize = [1, 1]
}
function onChange() {
$nodeValue = _value || []
}
function onUpload() {
}
function onClear() {
}
interface GradioUploadResponse {
error?: string;
files?: Array<string>;
}
async function upload_files(root: string, files: Array<File>): Promise<GradioUploadResponse> {
console.debug("UPLOADILFES", root, files);
const url = `http://${location.hostname}:8188` // TODO make configurable
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.cause)
}
else {
// bare filename of image
files.push((r as ComfyUploadImageAPIResponse).name)
}
}
let error = null;
if (errors && errors.length > 0)
error = "Upload error(s):\n" + errors.join("\n");
return { error, files }
})
}
$: {
if (JSON.stringify(_value) !== JSON.stringify(old_value)) {
pending_upload = true;
old_value = _value;
if (_value == null)
_value = []
else if (!Array.isArray(_value))
_value = [_value]
const allBlobs = _value.map((file_data: GradioFileData) => file_data.blob)
if (allBlobs == null || allBlobs.length === 0) {
_value = null;
onChange();
pending_upload = false;
}
else if (!allBlobs.every(b => b != null)) {
_value = null;
pending_upload = false;
}
else {
let files = (Array.isArray(_value) ? _value : [_value]).map(
(file_data) => file_data.blob!
);
let upload_value = _value;
pending_upload = true;
upload_files(root, files).then((response) => {
if (JSON.stringify(upload_value) !== JSON.stringify(_value)) {
// value has changed since upload started
console.error("[ImageUploadWidget] value has changed since upload started", upload_value, _value)
return;
}
pending_upload = false;
_value.forEach(
(file_data: GradioFileData, i: number) => {
if (response.files) {
file_data.orig_name = file_data.name;
file_data.name = response.files[i];
file_data.is_file = true;
}
}
);
if (response.error) {
notify(response.error, { type: "error" })
}
$nodeValue = normalise_file(_value, root, root_url) as GradioFileData[];
onChange();
onUpload();
});
}
}
}
async function handle_upload({ detail }: CustomEvent<GradioFileData | Array<GradioFileData>>) {
_value = Array.isArray(detail) ? detail : [detail];
await tick();
onChange();
onUpload();
}
function handle_clear(_e: CustomEvent<null>) {
_value = null;
onChange();
onClear();
}
function getImageUrl(image: GradioFileData) {
const baseUrl = `http://${location.hostname}:8188` // TODO make configurable
const params = new URLSearchParams({ filename: image.name, subfolder: "", type: "input" })
return `${baseUrl}/view?${params}`
}
</script>
<div class="wrapper gradio-file comfy-image-upload" style={widget.attrs.style}>
{#if widget && node && nodeValue}
<Block
visible={true}
variant={($nodeValue === null || $nodeValue.length === 0) ? "dashed" : "solid"}
border_mode={dragging ? "focus" : "base"}
padding={true}
elem_id="comfy-image-upload-block"
elem_classes={widget.attrs.classes.split(",")}
>
<BlockLabel
label={widget.attrs.title}
show_label={widget.attrs.title != ""}
Icon={FileIcon}
float={widget.attrs.title != ""}
/>
{#if _value && _value.length > 0 && !pending_upload}
{@const firstImage = _value[0]}
<ModifyUpload on:clear={handle_clear} absolute />
<img src={getImageUrl(firstImage)}
alt={firstImage.orig_name}
bind:this={imgElem}
bind:naturalWidth={imgWidth}
bind:naturalHeight={imgHeight}
/>
{:else}
<Upload
file_count={node.properties.fileCount}
filetype="image/*"
on:change={({ detail }) => ($nodeValue = detail)}
on:load={handle_upload}
bind:dragging
on:clear
on:select
parse_to_data_url={false}
>
<UploadText type="file" />
</Upload>
{/if}
</Block>
{/if}
</div>
<style lang="scss">
.comfy-image-upload {
height: var(--size-96);
:global(.block) {
height: inherit;
padding: 0;
border-radius: 0;
}
img {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
</style>

View File

@@ -1,5 +1,8 @@
import { LiteGraph } from '@litegraph-ts/core';
import App from './App.svelte';
LiteGraph.use_uuids = true;
const app = new App({
target: document.getElementById('app'),
})

View File

@@ -9,6 +9,7 @@ import ComfyGraph from '$lib/ComfyGraph';
Framework7.use(Framework7Svelte);
LiteGraph.use_uuids = true;
LiteGraph.dialog_close_on_mouse_leave = false;
LiteGraph.search_hide_on_mouse_leave = false;
LiteGraph.pointerevents_method = "pointer";

View File

@@ -5,6 +5,7 @@ import tsconfigPaths from 'vite-tsconfig-paths';
import FullReload from 'vite-plugin-full-reload';
import { viteStaticCopy } from 'vite-plugin-static-copy'
import removeConsole from 'vite-plugin-svelte-console-remover';
import glsl from 'vite-plugin-glsl';
const isProduction = process.env.NODE_ENV === "production";
console.log("Production build: " + isProduction)
@@ -20,6 +21,7 @@ export default defineConfig({
// "src/**/ComfyApp.{ts,svelte}"
// ]),
isProduction && removeConsole(),
glsl(),
svelte(),
viteStaticCopy({
targets: [