added new nodes

This commit is contained in:
Manny Gonzalez
2024-06-08 11:51:01 -04:00
parent d95ea3633e
commit d4bc28d802
8 changed files with 561 additions and 1 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
__pycache__
/pycache/*
/pycache/*
loaders/imgbb_api_key.json
+23
View File
@@ -42,6 +42,29 @@ Here is an example of the images Side by Side instead of split.
![Image Side-by-Side Generator](img/images2sidebyside.png)
### Image to imgBB
![Image to imgBB](img/image2imgbb.png)
These nodes enable uploading and downloading to / from the [imgBB](https://www.imgbb.com/) image sharing service. Also included are nodes for downloading images from imgBB and an image URL node that preserves the uploaded image URL in the workflow for easy sharing of originals with others.
Perfvect for sharing workflows while making original images available for others.
#### Setup
In order to use these nodes, you must have an account with imgBB service. Once you have your account, navigate to (https://api.imgbb.com/) and generate an API key. You will need to configure this key in the [imgbb_api_key.json] file in the nodes folder (./loaders/) folder.
There is a sample file [imgbb_api_key_example.json] you can copy and rename to [imgbb_api_key.json], edit it and enter your API key replacing the text "YOUR_API_KEY_HERE" with your key. See example below.
> {
> "api_key": "8a54a1b12353d43105d62fxadr3286a3323x"
> }
### Smart Checkpoint Loader
![Image to imgBB](img/smartCheckpointLoader.png)
This is a one-for-one replacement of the core Load Checkpoint node with one key difference: It flattens your directory structure regardless of how complex and makes all checkpoints appear as if on one folder. This is ideal for sharing workflows where the original author may have
a different directory structure than other users. Makes organizing checkpoints and sharing workflows easier.
## Examples
### Sample Workflow
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

+222
View File
@@ -0,0 +1,222 @@
"""
@app_author: YFG
@app_title: YFG Image to imgBB
@app_nickname: 🐯 YFG Image to imgBB
@app_description: This node loads an image, optionally uploads it to imgbb, and returns the image along with the image URL and API results.
"""
import io
import torch
import os
import sys
import json
import requests
import numpy as np
from PIL import Image, ImageOps, ImageSequence
from io import BytesIO
# Ensure the comfy directory is in the system path for imports
sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "comfy"))
from comfy.cli_args import args
import folder_paths
import node_helpers
class Image2ImgBB:
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
return {
"required": {
"image": (["None"] + sorted(files), {"image_upload": True}),
"upload_to_imgbb": ("BOOLEAN", {"default": False, "description": "Upload to imgBB?"})
}
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING")
RETURN_NAMES = ("IMAGE", "MASK", "Image URL", "API Response")
FUNCTION = "process_image"
CATEGORY = "🐯 YFG/Loaders"
FRIENDLY_NAME = "Image to imgBB"
def process_image(self, image, upload_to_imgbb, **kwargs):
graph_metadata = kwargs.get('graph_metadata', {})
img_url = graph_metadata.get('image_url', '')
output_image, output_mask, api_results = None, None, "No API call made"
image_url = img_url
# Attempt to load the image
if image != "None":
try:
image_path = folder_paths.get_annotated_filepath(image)
output_image, output_mask = self.load_local_image(image_path)
api_results = f"Loaded local image: {image}"
except (FileNotFoundError, ValueError) as e:
print(f"Local image loading failed: {e}")
# If local image loading failed, try to use the URL
if output_image is None and img_url:
if self.is_valid_url(img_url):
try:
print(f"Loading image from URL: {img_url}")
output_image, output_mask = self.load_image_from_url(img_url)
api_results = f"Using image from URL: {img_url}"
except Exception as e:
print(f"Failed to load image from URL: {e}")
api_results = f"Failed to load image from URL: {img_url}"
else:
print(f"Invalid URL: {img_url}")
api_results = f"Invalid URL: {img_url}"
# Upload the image to imgBB if required
if upload_to_imgbb and output_image is not None:
try:
api_results = self.upload_to_imgbb_binary(image_path)
api_response_json = json.loads(api_results)
img_url = api_response_json.get('data', {}).get('url', '')
if img_url:
graph_metadata['image_url'] = img_url
kwargs['graph_metadata'] = graph_metadata
image_url = img_url
print(f"Image uploaded to imgBB, URL saved: {img_url}")
except Exception as e:
print(f"Failed to upload image to imgBB: {e}")
api_results = f"Failed to upload image to imgBB: {e}"
print("API Response:", api_results)
return output_image, output_mask, image_url, api_results
def load_local_image(self, image_path):
if not os.path.exists(image_path):
raise FileNotFoundError(f"Local image file not found: {image_path}")
img = node_helpers.pillow(Image.open, image_path)
output_images, output_masks = [], []
w, h = None, None
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
image = i.convert("RGB")
if len(output_images) == 0:
w = image.size[0]
h = image.size[1]
if image.size[0] != w or image.size[1] != h:
raise ValueError("Image size mismatch")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image)
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1:
output_image = torch.cat(output_images, dim=0)
output_mask = torch.cat(output_masks, dim=0)
else:
output_image = output_images[0]
output_mask = output_masks[0]
return output_image, output_mask
def load_image_from_url(self, url):
response = requests.get(url, stream=True)
response.raise_for_status()
img = node_helpers.pillow(Image.open(BytesIO(response.content)))
output_images, output_masks = [], []
w, h = None, None
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
image = i.convert("RGB")
if len(output_images) == 0:
w = image.size[0]
h = image.size[1]
if image.size[0] != w or image.size[1] != h:
raise ValueError("Image size mismatch")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image)
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1:
output_image = torch.cat(output_images, dim=0)
output_mask = torch.cat(output_masks, dim=0)
else:
output_image = output_images[0]
output_mask = output_masks[0]
return output_image, output_mask
def upload_to_imgbb_binary(self, image_path):
api_key_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "imgbb_api_key.json")
try:
with open(api_key_path, "r") as key_file:
api_key = json.load(key_file)["api_key"]
except FileNotFoundError as fnf_error:
print(f"File not found: {fnf_error}")
return f"Error: {fnf_error}"
except json.JSONDecodeError as json_error:
print(f"Error decoding JSON: {json_error}")
return f"Error: {json_error}"
except Exception as e:
print(f"Unexpected error: {e}")
return f"Error: {e}"
with open(image_path, "rb") as image_file:
image_data = image_file.read()
url = "https://api.imgbb.com/1/upload"
files = {"image": (os.path.basename(image_path), image_data)}
payload = {
"key": api_key,
}
try:
response = requests.post(url, files=files, data=payload)
response.raise_for_status()
result = response.json()
print("API Response:", json.dumps(result, indent=2))
return json.dumps(result, indent=2)
except requests.exceptions.RequestException as e:
print(f"Error uploading image: {e}")
return f"Error uploading image: {e}"
def is_valid_url(self, url):
return url.startswith('http://') or url.startswith('https://')
@classmethod
def IS_CHANGED(cls, image):
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
m.update(f.read())
return m.digest().hex()
@classmethod
def VALIDATE_INPUTS(cls, image):
if not folder_paths.exists_annotated_filepath(image):
return f"Invalid image file: {image}"
return True
+254
View File
@@ -0,0 +1,254 @@
"""
@app_author: YFG
@app_title: YFG Image Loader from imgBB
@app_nickname: 🐯 YFG imgBB Loader
@app_description: This node loads an image from a provided URL and outputs the image and mask.
"""
import io
import torch
import requests
import numpy as np
from PIL import Image, ImageOps, ImageSequence
from io import BytesIO
import os
import folder_paths
import comfy.utils
from typing import List, Dict, Tuple
from comfy.cli_args import args
import sys
# Ensure the comfy directory is in the system path for imports
sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "comfy"))
def tensor2pil(image: torch.Tensor, mode=None):
return numpy2pil(image.cpu().numpy().squeeze(), mode=mode)
def pil2numpy(image: Image.Image):
return np.array(image).astype(np.float32) / 255.0
def numpy2pil(image: np.ndarray, mode=None):
return Image.fromarray(np.clip(255.0 * image, 0, 255).astype(np.uint8), mode)
def prepare_image_for_preview(image: Image.Image, output_dir: str, prefix=None):
if prefix is None:
prefix = "preview_" + "".join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
# Save image to temp folder
(
outdir,
filename,
counter,
subfolder,
_,
) = folder_paths.get_save_image_path(prefix, output_dir, image.width, image.height)
file = f"{filename}_{counter:05}_.png"
image.save(os.path.join(outdir, file), format="PNG", compress_level=4)
return {
"filename": file,
"subfolder": subfolder,
"type": "temp",
}
def load_images_from_url(urls: List[str], keep_alpha_channel=False):
images = []
masks = []
for url in urls:
if url.startswith("data:image/"):
i = Image.open(io.BytesIO(base64.b64decode(url.split(",")[1])))
elif url.startswith("file://"):
url = url[7:]
if not os.path.isfile(url):
raise Exception(f"File {url} does not exist")
i = Image.open(url)
elif url.startswith("http://") or url.startswith("https://"):
response = requests.get(url, timeout=5)
if response.status_code != 200:
raise Exception(response.text)
i = Image.open(io.BytesIO(response.content))
elif url.startswith("/view?"):
from urllib.parse import parse_qs
qs = parse_qs(url[6:])
filename = qs.get("name", qs.get("filename", None))
if filename is None:
raise Exception(f"Invalid url: {url}")
filename = filename[0]
subfolder = qs.get("subfolder", None)
if subfolder is not None:
filename = os.path.join(subfolder[0], filename)
dirtype = qs.get("type", ["input"])
if dirtype[0] == "input":
url = os.path.join(folder_paths.get_input_directory(), filename)
elif dirtype[0] == "output":
url = os.path.join(folder_paths.get_output_directory(), filename)
elif dirtype[0] == "temp":
url = os.path.join(folder_paths.get_temp_directory(), filename)
else:
raise Exception(f"Invalid url: {url}")
i = Image.open(url)
elif url == "":
continue
else:
url = folder_paths.get_annotated_filepath(url)
if not os.path.isfile(url):
raise Exception(f"Invalid url: {url}")
i = Image.open(url)
i = ImageOps.exif_transpose(i)
has_alpha = "A" in i.getbands()
mask = None
if "RGB" not in i.mode:
i = i.convert("RGBA") if has_alpha else i.convert("RGB")
if has_alpha:
mask = i.getchannel("A")
# Recreate image to fix weird RGB image
alpha = i.split()[-1]
image = Image.new("RGB", i.size, (0, 0, 0))
image.paste(i, mask=alpha)
image.putalpha(alpha)
if not keep_alpha_channel:
image = image.convert("RGB")
else:
image = i
images.append(image)
masks.append(mask)
return (images, masks)
class ImgbbLoader:
def __init__(self) -> None:
self.output_dir = folder_paths.get_temp_directory()
self.filename_prefix = "TempImageFromUrl"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"url": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False}),
},
"optional": {
"keep_alpha_channel": (
"BOOLEAN",
{"default": False, "label_on": "enabled", "label_off": "disabled"},
),
"output_mode": (
"BOOLEAN",
{"default": False, "label_on": "list", "label_off": "batch"},
),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "BOOLEAN")
OUTPUT_IS_LIST = (True, True, False)
RETURN_NAMES = ("images", "masks", "has_image")
CATEGORY = "🐯 YFG/Loaders"
FUNCTION = "load_image_from_url"
def load_image_from_url(self, url="", keep_alpha_channel=False, output_mode=False, **kwargs):
if not url:
raise ValueError("No URL provided for image loading")
urls = url.strip().split("\n")
images, masks = load_images_from_url(urls, keep_alpha_channel)
if len(images) == 0:
image = torch.zeros((1, 64, 64, 3), dtype=torch.float32, device="cpu")
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
return ([image], [mask], False)
previews = []
np_images = []
np_masks = []
for image, mask in zip(images, masks):
# Save image to temp folder
preview = prepare_image_for_preview(image, self.output_dir, self.filename_prefix)
image_tensor = self.pil2tensor(image)
if mask:
mask_tensor = np.array(mask).astype(np.float32) / 255.0
mask_tensor = 1.0 - torch.from_numpy(mask_tensor)
else:
mask_tensor = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
previews.append(preview)
np_images.append(image_tensor)
np_masks.append(mask_tensor.unsqueeze(0))
if output_mode:
result = (np_images, np_masks, True)
else:
has_size_mismatch = False
if len(np_images) > 1:
for image in np_images[1:]:
if image.shape[1] != np_images[0].shape[1] or image.shape[2] != np_images[0].shape[2]:
has_size_mismatch = True
break
if has_size_mismatch:
raise Exception("To output as batch, images must have the same size. Use list output mode instead.")
result = ([torch.cat(np_images)], [torch.cat(np_masks)], True)
return {"ui": {"images": previews}, "result": result}
def pil2tensor(self, image):
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0)[None,]
def is_valid_url(self, url):
return url.startswith('http://') or url.startswith('https://')
class storeURL:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
INPUT_IS_LIST = True
RETURN_TYPES = ("STRING",)
FUNCTION = "getURL"
OUTPUT_NODE = True
OUTPUT_IS_LIST = (True,)
CATEGORY = "🐯 YFG/Loaders"
def getURL(self, text, unique_id=None, extra_pnginfo=None):
if unique_id is not None and extra_pnginfo is not None:
if not isinstance(extra_pnginfo, list):
print("Error: extra_pnginfo is not a list")
elif (
not isinstance(extra_pnginfo[0], dict)
or "workflow" not in extra_pnginfo[0]
):
print("Error: extra_pnginfo[0] is not a dict or missing 'workflow' key")
else:
workflow = extra_pnginfo[0]["workflow"]
node = next(
(x for x in workflow["nodes"] if str(x["id"]) == str(unique_id[0])),
None,
)
if node:
node["widgets_values"] = [text]
return {"ui": {"text": text}, "result": (text,)}
+3
View File
@@ -0,0 +1,3 @@
{
"api_key": "YOUR_API_KEY_HERE"
}
+57
View File
@@ -0,0 +1,57 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
// Displays input text on a node
app.registerExtension({
name: "YFG.storeURL",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "storeURL_node") {
function populate(text) {
if (this.widgets) {
for (let i = 1; i < this.widgets.length; i++) {
this.widgets[i].onRemove?.();
}
this.widgets.length = 1;
}
const v = [...text];
if (!v[0]) {
v.shift();
}
for (const list of v) {
const w = ComfyWidgets["STRING"](this, "text", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.6;
w.value = list;
}
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the input text, display this in the widget
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
populate.call(this, message.text);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
if (this.widgets_values?.length) {
populate.call(this, this.widgets_values);
}
};
}
},
});