Image upload widget

This commit is contained in:
space-nuko
2023-05-08 18:08:28 -05:00
parent 5feffcfa17
commit 0f50dbae87
15 changed files with 380 additions and 48 deletions

View File

@@ -37,9 +37,11 @@
"@gradio/atoms": "workspace:*", "@gradio/atoms": "workspace:*",
"@gradio/button": "workspace:*", "@gradio/button": "workspace:*",
"@gradio/client": "workspace:*", "@gradio/client": "workspace:*",
"@gradio/file": "workspace:*",
"@gradio/form": "workspace:*", "@gradio/form": "workspace:*",
"@gradio/gallery": "workspace:*", "@gradio/gallery": "workspace:*",
"@gradio/icons": "workspace:*", "@gradio/icons": "workspace:*",
"@gradio/image": "workspace:*",
"@gradio/tabs": "workspace:*", "@gradio/tabs": "workspace:*",
"@gradio/theme": "workspace:*", "@gradio/theme": "workspace:*",
"@gradio/upload": "workspace:*", "@gradio/upload": "workspace:*",

6
pnpm-lock.yaml generated
View File

@@ -16,6 +16,9 @@ importers:
'@gradio/client': '@gradio/client':
specifier: workspace:* specifier: workspace:*
version: link:gradio/client/js version: link:gradio/client/js
'@gradio/file':
specifier: workspace:*
version: link:gradio/js/file
'@gradio/form': '@gradio/form':
specifier: workspace:* specifier: workspace:*
version: link:gradio/js/form version: link:gradio/js/form
@@ -25,6 +28,9 @@ importers:
'@gradio/icons': '@gradio/icons':
specifier: workspace:* specifier: workspace:*
version: link:gradio/js/icons version: link:gradio/js/icons
'@gradio/image':
specifier: workspace:*
version: link:gradio/js/image
'@gradio/tabs': '@gradio/tabs':
specifier: workspace:* specifier: workspace:*
version: link:gradio/js/tabs version: link:gradio/js/tabs

View File

@@ -78,7 +78,7 @@
<div class="animation-wrapper" <div class="animation-wrapper"
class:hidden={hidden} class:hidden={hidden}
animate:flip={{duration:flipDurationMs}} animate:flip={{duration:flipDurationMs}}
style={item?.attrs?.flexGrow ? `flex-grow: ${item.attrs.flexGrow}` : ""} style={item?.attrs?.style || ""}
> >
<WidgetContainer dragItem={item} zIndex={zIndex+1} {isMobile} /> <WidgetContainer dragItem={item} zIndex={zIndex+1} {isMobile} />
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]} {#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
@@ -183,6 +183,7 @@
.animation-wrapper { .animation-wrapper {
position: relative; position: relative;
flex-grow: 100; flex-grow: 100;
flex-basis: 0;
} }
.handle-widget:hover { .handle-widget:hover {

View File

@@ -80,7 +80,7 @@
<div class="animation-wrapper" <div class="animation-wrapper"
class:hidden={hidden} class:hidden={hidden}
animate:flip={{duration:flipDurationMs}} animate:flip={{duration:flipDurationMs}}
style={item?.attrs?.flexGrow ? `flex-grow: ${item.attrs.flexGrow}` : ""} style={item?.attrs?.style || ""}
> >
<WidgetContainer dragItem={item} zIndex={zIndex+1} {isMobile} /> <WidgetContainer dragItem={item} zIndex={zIndex+1} {isMobile} />
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]} {#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
@@ -238,6 +238,7 @@
.animation-wrapper { .animation-wrapper {
position: relative; position: relative;
flex-grow: 100; flex-grow: 100;
flex-basis: 0;
} }
.handle-hidden { .handle-hidden {

View File

@@ -715,7 +715,8 @@ export default class ComfyApp {
comfyInput.config.values = def["input"]["required"][comfyInput.name][0]; comfyInput.config.values = def["input"]["required"][comfyInput.name][0];
const inputNode = node.getInputNode(index) const inputNode = node.getInputNode(index)
if (inputNode && "doAutoConfig" in inputNode) { if (inputNode && "doAutoConfig" in inputNode && comfyInput.widgetNodeType === inputNode.type) {
console.debug("[ComfyApp] Reconfiguring combo widget", inputNode.type, comfyInput.config.values)
const comfyComboNode = inputNode as nodes.ComfyComboNode; const comfyComboNode = inputNode as nodes.ComfyComboNode;
comfyComboNode.doAutoConfig(comfyInput) comfyComboNode.doAutoConfig(comfyInput)
if (!comfyInput.config.values.includes(get(comfyComboNode.value))) { if (!comfyInput.config.values.includes(get(comfyComboNode.value))) {

View File

@@ -92,7 +92,7 @@
<div class="animation-wrapper" <div class="animation-wrapper"
class:hidden={hidden} class:hidden={hidden}
animate:flip={{duration:flipDurationMs}} animate:flip={{duration:flipDurationMs}}
style={item?.attrs?.flexGrow ? `flex-grow: ${item.attrs.flexGrow}` : ""}> style={item?.attrs?.style || ""}>
<Block> <Block>
<label for={String(item.id)}> <label for={String(item.id)}>
<BlockTitle><strong>Tab {i+1}:</strong> {tabName}</BlockTitle> <BlockTitle><strong>Tab {i+1}:</strong> {tabName}</BlockTitle>
@@ -198,6 +198,7 @@
.animation-wrapper { .animation-wrapper {
position: relative; position: relative;
flex-grow: 100; flex-grow: 100;
flex-basis: 0;
} }
.handle-widget:hover { .handle-widget:hover {

View File

@@ -0,0 +1,38 @@
<script lang="ts">
export let type: "video" | "image" | "audio" | "file" | "csv" = "file";
const defs = {
image: "interface.drop_image",
video: "interface.drop_video",
audio: "interface.drop_audio",
file: "interface.drop_file",
csv: "interface.drop_csv"
};
</script>
<div class="wrap">
{"Drop Image here"}
<span class="or">- {"or"} -</span>
{"Click to Upload"}
</div>
<style>
.wrap {
display: flex;
flex-direction: column;
justify-content: center;
min-height: var(--size-60);
color: var(--block-label-text-color);
line-height: var(--line-md);
}
.or {
color: var(--body-text-color-subdued);
}
@media (--screen-md) {
.wrap {
font-size: var(--text-lg);
}
}
</style>

View File

@@ -207,21 +207,29 @@ LiteGraph.registerNodeType({
}) })
export interface ComfyExecuteSubgraphActionProperties extends ComfyGraphNodeProperties { export interface ComfyExecuteSubgraphActionProperties extends ComfyGraphNodeProperties {
tag: string | null, targetTag: string
} }
export class ComfyExecuteSubgraphAction extends ComfyGraphNode { export class ComfyExecuteSubgraphAction extends ComfyGraphNode {
override properties: ComfyExecuteSubgraphActionProperties = { override properties: ComfyExecuteSubgraphActionProperties = {
tag: null tags: [],
targetTag: ""
} }
static slotLayout: SlotLayout = { static slotLayout: SlotLayout = {
inputs: [ inputs: [
{ name: "execute", type: BuiltInSlotType.ACTION }, { name: "execute", type: BuiltInSlotType.ACTION },
{ name: "tag", type: "string" } { name: "targetTag", type: "string" }
], ],
} }
displayWidget: ITextWidget;
constructor(title?: string) {
super(title)
this.displayWidget = this.addWidget("text", "targetTag", this.properties.targetTag, "targetTag")
}
override onExecute() { override onExecute() {
const tag = this.getInputData(1) const tag = this.getInputData(1)
if (tag) if (tag)
@@ -229,7 +237,7 @@ export class ComfyExecuteSubgraphAction extends ComfyGraphNode {
} }
override onAction(action: any, param: any) { override onAction(action: any, param: any) {
const tag = this.getInputData(1) || this.properties.tag; const tag = this.getInputData(1) || this.properties.targetTag;
const app = (window as any)?.app; const app = (window as any)?.app;
if (!app) if (!app)

View File

@@ -12,6 +12,10 @@ export interface ComfyImageCacheNodeProperties extends ComfyGraphNodeProperties
type ImageCacheState = "none" | "uploading" | "failed" | "cached" type ImageCacheState = "none" | "uploading" | "failed" | "cached"
interface ComfyUploadImageAPIResponse {
name: string
}
/* /*
* A node that can act as both an input and output image node by uploading * A node that can act as both an input and output image node by uploading
* the output file into ComfyUI's input folder. * the output file into ComfyUI's input folder.
@@ -159,6 +163,7 @@ export default class ComfyImageCacheNode extends ComfyGraphNode {
else { else {
this.properties.filenames[newIndex] = { filename: null, status: "uploading" } this.properties.filenames[newIndex] = { filename: null, status: "uploading" }
this.onPropertyChanged("filenames", this.properties.filenames) this.onPropertyChanged("filenames", this.properties.filenames)
const url = `http://${location.hostname}:8188` // TODO make configurable const url = `http://${location.hostname}:8188` // TODO make configurable
const params = new URLSearchParams(data) const params = new URLSearchParams(data)
@@ -176,10 +181,10 @@ export default class ComfyImageCacheNode extends ComfyGraphNode {
) )
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((json) => { .then((json: ComfyUploadImageAPIResponse) => {
console.debug("Gottem", json) console.debug("Gottem", json)
if (lastGenNumber === this.properties.genNumber) { if (lastGenNumber === this.properties.genNumber) {
this.properties.filenames[newIndex] = { filename: data.filename, status: "cached" } this.properties.filenames[newIndex] = { filename: json.name, status: "cached" }
this.onPropertyChanged("filenames", this.properties.filenames) this.onPropertyChanged("filenames", this.properties.filenames)
} }
else { else {

View File

@@ -16,6 +16,7 @@ import GalleryWidget from "$lib/widgets/GalleryWidget.svelte";
import ButtonWidget from "$lib/widgets/ButtonWidget.svelte"; import ButtonWidget from "$lib/widgets/ButtonWidget.svelte";
import CheckboxWidget from "$lib/widgets/CheckboxWidget.svelte"; import CheckboxWidget from "$lib/widgets/CheckboxWidget.svelte";
import RadioWidget from "$lib/widgets/RadioWidget.svelte"; import RadioWidget from "$lib/widgets/RadioWidget.svelte";
import ImageUploadWidget from "$lib/widgets/ImageUploadWidget.svelte";
/* /*
* NOTE: If you want to add a new widget but it has the same input/output type * NOTE: If you want to add a new widget but it has the same input/output type
@@ -79,6 +80,8 @@ export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
override isBackendNode = false; override isBackendNode = false;
override serialize_widgets = true; override serialize_widgets = true;
// TODO these are bad, create override methods instead
// input slots // input slots
inputIndex: number = 0; inputIndex: number = 0;
@@ -86,6 +89,7 @@ export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
outputIndex: number | null = 0; outputIndex: number | null = 0;
changedIndex: number | null = 1; changedIndex: number | null = 1;
displayWidget: ITextWidget; displayWidget: ITextWidget;
override size: Vector2 = [60, 40]; override size: Vector2 = [60, 40];
@@ -203,7 +207,7 @@ export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
): boolean { ): boolean {
const anyConnected = range(this.outputs.length).some(i => this.getOutputLinks(i).length > 0); const anyConnected = range(this.outputs.length).some(i => this.getOutputLinks(i).length > 0);
if (this.autoConfig && "config" in input && !anyConnected) { if (this.autoConfig && "config" in input && !anyConnected && (input as IComfyInputSlot).widgetNodeType === this.type) {
this.doAutoConfig(input as IComfyInputSlot) this.doAutoConfig(input as IComfyInputSlot)
} }
@@ -751,3 +755,52 @@ LiteGraph.registerNodeType({
desc: "Radio that outputs a string and index", desc: "Radio that outputs a string and index",
type: "ui/radio" type: "ui/radio"
}) })
export interface ComfyImageUploadProperties extends ComfyWidgetProperties {
fileCount: "single" | "multiple" // gradio File component format
}
export class ComfyImageUploadNode extends ComfyWidgetNode<Array<GradioFileData>> {
override properties: ComfyImageUploadProperties = {
defaultValue: [],
tags: [],
fileCount: "single",
}
static slotLayout: SlotLayout = {
outputs: [
{ name: "filename", type: "string" }, // TODO support batches
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override svelteComponentType = ImageUploadWidget;
override defaultValue = null;
override outputIndex = null;
override changedIndex = 1;
constructor(name?: string) {
super(name, [])
}
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
else
this.setOutputData(0, "")
}
override formatValue(value: GradioFileData[]): string {
return `Images: ${value.length}`
}
}
LiteGraph.registerNodeType({
class: ComfyImageUploadNode,
title: "UI.ImageUpload",
desc: "Widget that lets you upload images into ComfyUI's input folder",
type: "ui/image_upload"
})

View File

@@ -4,7 +4,7 @@ import { f7 } from "framework7-svelte"
let notification; let notification;
function notifyf7(text: string, title?: string) { function notifyf7(text: string, title?: string, type?: string) {
if (!f7) if (!f7)
return; return;
@@ -22,7 +22,7 @@ function notifyf7(text: string, title?: string) {
notification.open(); notification.open();
} }
function notifyToast(text: string, type?: string) { function notifyToast(text: string, title?: string, type?: string) {
const options: SvelteToastOptions = {} const options: SvelteToastOptions = {}
if (type === "error") { if (type === "error") {
@@ -35,6 +35,6 @@ function notifyToast(text: string, type?: string) {
} }
export default function notify(text: string, title?: string, type?: string) { export default function notify(text: string, title?: string, type?: string) {
notifyf7(text, title); notifyf7(text, title, type);
notifyToast(text, title); notifyToast(text, title, type);
} }

View File

@@ -133,14 +133,9 @@ export type Attributes = {
disabled?: boolean, disabled?: boolean,
/* /*
* CSS height * CSS styles
*/ */
height?: string, style?: string,
/*
* CSS Flex grow
*/
flexGrow?: number,
/** /**
* Display variant for widgets/containers (e.g. number widget can act as slider/knob/dial) * Display variant for widgets/containers (e.g. number widget can act as slider/knob/dial)
@@ -320,20 +315,6 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: "vertical", defaultValue: "vertical",
canShow: (di: IDragItem) => di.type === "container" canShow: (di: IDragItem) => di.type === "container"
}, },
{
name: "flexGrow",
type: "number",
location: "widget",
defaultValue: 100,
editable: true
},
{
name: "height",
type: "string",
location: "widget",
defaultValue: "auto",
editable: true
},
{ {
name: "classes", name: "classes",
type: "string", type: "string",
@@ -341,6 +322,13 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
defaultValue: "", defaultValue: "",
editable: true, editable: true,
}, },
{
name: "style",
type: "string",
location: "widget",
defaultValue: "",
editable: true
},
{ {
name: "nodeDisabledState", name: "nodeDisabledState",
type: "enum", type: "enum",

View File

@@ -136,7 +136,6 @@
:global(.svelte-select) { :global(.svelte-select) {
width: auto; width: auto;
max-width: 16rem;
--font-size: 13px; --font-size: 13px;
--height: 32px; --height: 32px;
} }

View File

@@ -3,6 +3,7 @@
import { Block, BlockLabel, Empty } from "@gradio/atoms"; import { Block, BlockLabel, Empty } from "@gradio/atoms";
import { Gallery } from "@gradio/gallery"; import { Gallery } from "@gradio/gallery";
import { Image } from "@gradio/icons"; import { Image } from "@gradio/icons";
import { StaticImage } from "@gradio/image";
import type { Styles } from "@gradio/utils"; import type { Styles } from "@gradio/utils";
import type { WidgetLayout } from "$lib/stores/layoutState"; import type { WidgetLayout } from "$lib/stores/layoutState";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
@@ -38,7 +39,7 @@
let style: Styles = { let style: Styles = {
grid_cols: [4], grid_cols: [4],
grid_rows: [4], grid_rows: [4],
// object_fit: "cover", object_fit: "cover",
} }
let element: HTMLDivElement; let element: HTMLDivElement;
@@ -126,24 +127,21 @@
{#if widget && node && nodeValue && $nodeValue != null} {#if widget && node && nodeValue && $nodeValue != null}
{#if widget.attrs.variant === "image"} {#if widget.attrs.variant === "image"}
<div class="wrapper comfy-image-widget" style="height: {widget.attrs.height || 'auto'}" bind:this={element}> <div class="wrapper comfy-image-widget" style={widget.attrs.style || ""} bind:this={element}>
<Block variant="solid" padding={false}> <Block variant="solid" padding={false}>
{#if widget.attrs.title}
<BlockLabel
show_label={true}
Icon={Image}
label={widget.attrs.title || "Image"}
/>
{/if}
{#if $nodeValue.length > 0} {#if $nodeValue.length > 0}
<img src={$nodeValue[$nodeValue.length-1].data}/> <StaticImage
value={$nodeValue[$nodeValue.length-1].data}
show_label={widget.attrs.title != ""}
label={widget.attrs.title}
/>
{:else} {:else}
<Empty size="large" unpadded_box={true}><Image /></Empty> <Empty size="large" unpadded_box={true}><Image /></Empty>
{/if} {/if}
</Block> </Block>
</div> </div>
{:else} {:else}
<div class="wrapper comfy-gallery-widget gradio-gallery" bind:this={element}> <div class="wrapper comfy-gallery-widget gradio-gallery" style={widget.attrs.style || ""} bind:this={element}>
<Block variant="solid" padding={false}> <Block variant="solid" padding={false}>
<div class="padding"> <div class="padding">
<Gallery <Gallery

View File

@@ -0,0 +1,231 @@
<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";
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;
$: 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;
}
};
function onChange() {
console.warn("CHANGED", _value)
$nodeValue = _value || []
}
function onUpload() {
console.warn("UPLOADED", _value)
}
function onClear() {
console.warn("CLEARED", _value)
}
interface GradioUploadResponse {
error?: string;
files?: Array<string>;
}
async function upload_files(root: string, files: Array<File>): Promise<GradioUploadResponse> {
console.warn("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;
console.warn(_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, null, "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
console.warn(image)
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} />
{: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>